ericpzh

ac27-editor

AC27 Editor — Electron desktop app for editing Airport Control 27.acl flight schedule files. Use this skill whenever working in this repo, editing any source file, running commands (npm start, node build.js, npm test, node tests/integration/*), adding features, fixing bugs, or discussing the app's architecture. This skill documents the full project structure, coding conventions, IPC patterns, save/load flow, timeline system, build process, and all dev commands. Always consult this skill before making changes.

ericpzh 6 1 Updated 4w ago

Resources

1
GitHub

Install

npx skillscat add ericpzh/ac27leveleditor/ac27-editor

Install via the SkillsCat registry.

SKILL.md

AC27 Editor — Repo Skill

Project Identity

  • Name: ac27-editor
  • Purpose: Cross-platform desktop level editor for Airport Control 27 .acl flight schedule files
  • Stack: Electron + React + Vite + zustand
  • Entry: electron/main.js (Electron main process) + src/main.jsx (React renderer)
  • App ID: com.ac27.editor
  • MCP Integration: Built-in HTTP API server (port 31415) + MCP tools for AI agent control (see ac27-editor-mcp skill)
  • Cloud LLM Chat: In-app chat panel powered by DeepSeek / Gemini / Claude / Codex — 4-vendor multi-model chat with tool calling (see references/mcp-integration.md)

Architecture Overview

Layer Key Files Details
Electron Main Process electron/main.js Creates BrowserWindow (1400×880, min 1280×640), contextIsolation: true, nodeIntegration: false, ipcMain.handle() endpoints for all file I/O/dialog/caching, plus an ipcMain.on('app-quit') listener to close the editor after the restore-all success prompt
electron/updater.js Auto-update: HEAD check (R2 ETag), MD5 comparison, file-based logging to <userData>/updater.log, resolveTargetExe() (dev-mode support), exe download (downloadUpdate() resolves { filePath, download } — the GET response's etag/lastModified/ac27Md5/contentLength/received + startedAt/receivedAt), batch script generation, download verification prefers the Worker's X-AC27-MD5 response header (read from the SAME GET that streamed the file — no post-download re-HEAD race) and falls back to the post-download HEAD etag for older Workers; both hashes + both response timestamps are logged to updater.log and returned as diagnostics on UPDATE_MD5_MISMATCH, post_update_pending.json flag (markPostUpdatePending/checkPostUpdatePending/clearPostUpdatePending). Normal+voice auto-update through ONE /editor route — voice (detected via resources/voice-stt-vosk.js, isVoiceBuild()) sends X-AC27-Variant: voice so Worker serves AC27EditorVoice.exe(.md5); workshop variant (--workshop, resources/workshop.json, AC27EditorWorkshop.exe) disables auto-update entirely (isWorkshopBuild() checks marker, isUpdateSupported()+checkForUpdate() skip, movable — marker travels inside portable bundle, Steam Workshop handles updates). Workshop also ships the AC27Approach plugin DLL offline — build.js --workshop bundles mods/AC27Approach/bin/*/AC27Approach.dll as resources/AC27Approach.dll when present, and release.yml copies release-plugin/AC27Approach.dll as sibling steam-workshop-content/AC27Approach.dll; resolveWorkshopBundledDllPath() (electron/workshop-dll.js, unit-tested in tests/electron/workshop-dll.test.js) prefers the game-root-derived Workshop item dir (livery.workshopContentDir(gameRoot) + <appid>/<publishedfileid>/AC27Approach.dll), then resources/, then the legacy <exe-dir> fallback, with AC27_WORKSHOP_DIR as the top override
electron/api-server.js HTTP API + MCP (port 31415)
electron/cloud-llm.js Multi-vendor cloud LLM chat
electron/bepinex.js BepInEx Debug Mode — download/install/uninstall the IL2CPP x64 runtime for the game. installLatest(gameRoot, onProgress) = resolve URL → downloadZip → extractZip (PowerShell Expand-Archive) → copy the 4 REQUIRED_ITEMS (BepInEx, dotnet, doorstop_config.ini, winhttp.dll) into the game root; checkStatus reports installed from the BepInEx/ folder alone (missing[] still lists absent loader files for diagnostics — a partial leftover with folder present reads ON so the menu offers Uninstall/repair instead of stranding on OFF); removeFiles throws BEPINEX_REMOVE_FAILED when BepInEx/ survives (game-run lock) and uninstall-bepinex returns success:false on any per-item error (never silent-success); hasApproachPlugin/approachPluginPath gate the AC27Approach plugin. URL discovery is a fallback chain: findBleedingEdgeUrl() scrapes https://builds.bepinex.dev/projects/bepinex_be (primary), then findThunderstoreUrl() reads the Thunderstore BepInExPack_IL2CPP package API (https://thunderstore.io/api/experimental/package/BepInEx/BepInExPack_IL2CPP/ → latest.version_number + latest.download_url; same BepInEx/+dotnet/+doorstop+winhttp layout under a BepInExPack/ wrapper folder, which installFiles() handles) when the build server is unreachable; findDownloadUrl() returns the first success (tagged with source) and throws BEPINEX_ALL_SOURCES_FAILED when every source fails (surfaced via the overlay's bepinex_error_sources string). Browser toggle (BrowserScreen.jsx:handleToggleDebugMode) re-probes check-bepinex before branching + after uninstall, re-probes on every settings-menu open, keeps the toggle ON on failure and shows bepinex_uninstall_locked (close-the-game, zh+en; NO_GAME_ROOT keeps bepinex_error_game_root). A successful enable pops a restart-the-game modal (bepinex_restart_title/bepinex_restart_body, zh+en — BepInEx loads at game startup). Unit-tested in tests/electron/bepinex.test.js (incl. folder-only partial → ON) + the restart-prompt case in tests/components/BrowserScreen/BrowserScreen.test.jsx; the overlay case in tests/components/BrowserScreen/BepInExInstallOverlay.test.jsx
Preload Bridge electron/preload.js contextBridge exposing window.electronAPI (IPC methods + event on/off helpers) (checkForUpdate/downloadUpdate/installUpdate + checkPostUpdatePending/clearPostUpdatePending + resetAllLevels + loadGroundPainterData/saveGroundPainterData + quitApp), onStoreApiUpdate/offStoreApiUpdate for MCP bridge, each method = ipcRenderer.invoke(channel,...args) except quitApp (ipcRenderer.send('app-quit'))
Vite + React Entry index.html, src/main.jsx <div id="root"> rendered by ReactDOM.createRoot, Vite bundles src/ → dist/, three screens: setup → browser → editor
React Components src/components/
App.jsx Root: I18nProvider + ScreenRouter + Modal + Toast + post-update nudge (update_post_* → restore-all). ScreenRouter gates on langStatus from useTranslation: loading → spinner, unset (no localStorage/cache.json language) → first-launch LanguagePicker, else the setup/browser/editor switch
SetupScreen, LanguagePicker, BrowserScreen, EditorScreen Setup (game root; Workshop build auto-detects, normal build is manual-only), first-launch language picker, level browser, editor
BrowserScreen useTooltip (shared tooltip hook), VideoReplaceOverlay (menu video replacer), VideoBackgroundModal (video replace/restore modal), BrowserHelpOverlay (help overlay, has its own Header Buttons + Settings Menu sections), header = Livery (browser_livery, AiFillSkin accent-tinted → screen='livery') + Restore All (browser_restore_all, MdOutlineRestore → resetAllLevels → Steam verify) + Setting (browser_settings, FaGear → floating .browser-settings-menu dropdown, closes on outside-click/Escape) + Help (? icon-only), Settings menu hosts Change Folder (hover shows cached rootPath instantly via portal tooltip browser_change_dir_hint, no path displayed outright) + Background Video + Report Bug + Language (label shows the other language's name: 中文 in en mode, English in zh mode) + Dark/Light Mode + Debug Mode last (stays open on toggle so its active/loading state is visible), level-scan loading overlay (while loading the main list renders nothing; a fixed .browser-scan-overlay — blurred backdrop + centered spinner + browser_loading — covers the whole screen incl. the header until every airport's scan resolves), collapsible airport cards (click a card header to toggle its level rows; a one-shot session-scoped auto-collapse keeps all airport headers visible — see the "Collapsible airport cards" note)
EditorScreen FlightTable, TimelineEditors (RunwayEditor: blocks duplicate time (addChange nudge + chgTime toast) + empty changes + source-not-active chronological sweep (allows 01→19, 04→22 / 19→01 / 01→19 recurrence) + inactive arrival runway via runTripleValidation, flight_plans.js sorts RunwayTimeline before emit), CellEditor, SearchBar, TutorialOverlay, GroundPainter (dedicated scenery-editing window)
common/ Modal, Toast
ChatPanel/ Floating chat panel with cloud LLM integration (4 vendors, tool calling)
Custom Hooks src/hooks/ useTranslation, useElectronAPI, useEditorShell, useEditorSaveActions, useKeyboardShortcuts, useDrag
hooks/map/ Shared hooks for map windows: useCrossWindowSelection, useWitchAnimation, useKnobPositions
State Management src/store/appStore.js Zustand single store: screen, flights, timelines, modal/toast, chat, map overlay state, radar window tracking, UDP health, browser cache (fileInfos/geomCache/browserDataLoaded persists across editor→browser nav) + browser collapse state (browserCollapsedAirports/browserAutoCollapsed/browserAutoCollapseDone, session-scoped — see the "Collapsible airport cards" note)
src/store/flightDefaults.js Pure helpers for new flight creation: random airline code (audio → AirlineCode dropdown → AirlineName → 'NEW'), airline-independent random aircraft type + cascaded registration, non-conflicting random stand, random runway with STAR constrained to runway's valid procedures (arrivals pick from _runwayStarMap[runway]; departures ALWAYS clear Airway to '' — never inherit the airport-wide STAR list, the SID is auto-derived at runtime from the runway), airport-aware Language default (Z* ICAO → 'zh', else 'en') overridden by the airline rule — languageForAirline(airlineCode, values._airlineCountries, values.Language) (CN carriers → zh, every other carrier → en; see src/utils/airlineLanguage.js), Voice picked by pickVoiceForLanguage(values, language) so the voice's voice_catalog.json language matches the flight (the game throws at level load otherwise — airportValues._voiceLanguages). New flights get AirlineName = the picked airline code (game stores codes, not names) and isDeparture set from the requested type
src/store/flightCascade.js Pure helpers for cascading field updates (CallSign rebuild, airline→reg — aircraft type is airline-independent, runway→STAR, language→voice, airline→language+voice cascadeAirlineLanguage: switching to a non-CN carrier forces en+an en voice, a CN carrier forces zh when the airport ships Chinese audio)
ACL Backend src/acl/parser.js Facade — main.js imports ALL backend modules through it only (backend modules, CommonJS + some ESM)
src/acl/config.js Centralized time resolution: resolveConfigTime(rawText) extracts Config block and overrides startTime with ceil-rounded GameTime.CurrentDateTime (rounded UP to next whole minute). resolveDisplayTimes() for UI. Lazy-requires to avoid circular deps
src/acl/constants.js CJS re-export of utils/constants.js (single source of truth — add new constants there)
src/acl/gatcarc.js GATCARC4 binary container — universal readAclText() / writeAcl() for transparent read/write of the GATCARC4 binary format (magic: "GATCARC4", SHA-256 integrity, append checkpoint frames). Two storage versions: v1 (Odin-binary header + NODH) and v2 (UTF-8 JSON header {schemaHash, archiveGuid, levelGuid, airportIcao} with NODS, a length-prefixed main LevelPayload Odin segment with NODH, and checkpoint frames whose internal [4 jsonLen][json][32 hash][4 binLen][bin][32 hash][NODF] embeds the commit with no outer hash). readAclText/writeAcl accept both; writeAcl preserves the on-disk version (v2 stays v2, v1 stays v1, new files are v2). Both the v2 main LevelPayload and every decodable checkpoint frame are user-editable; the JSON header is preserved opaquely and each checkpoint frame is re-encoded from its edited decoded text (opaque frame JSON metadata preserved, inner [binLen][bin][binHash] + NODF recomputed) so save-pipeline frame edits persist. writeAcl() renumbers the text through src/acl/id_renumber.js first (strictly ascending $id/$iref in text order per document — the game's checkpoint reader requires it; the rebuild emits ids in construction order, which can descend vs text order)
src/acl/v4_pk_index.js v4 PK index builder — indexed lookup of StaticData.$blobdoc.PKStaticEntities.$rcontent entities by type prefix (runway:, stand:, taxiway-segment:, etc.) with $iref → $id resolution. Field helpers: extractVector3FromV4, extractStringFromV4, extractIrefArray, extractSingleIref, extractIntFromV4
src/acl/scenery_graph.js Ground Painter read path (id-free Graph) — buildSceneryGraph(text)→{graph,meta} (nodes/segments/runways/areas/stands, shared nodes deduped by coordKey), getBlobTypeMap, findNodeIndex, rebuildOwners/emptyGraph/cloneGraph. Reads Flags/Type raw (preserves 0 via extractIntFromV4), PhysicalRunwayStaticItem (v5) with top-level fallback, Areas `30
src/acl/scenery_write.js Ground Painter write path (also synthesizes stand companions (taxi-navigation:stand/:pushback + jetway StaticItems), runway derived geometry (edge/touchdown nodes + holding areas), retro-heals survivor taxi-nav, and syncs survivor companion positions/names — see references/acl-format.md 7e) — patchSceneryBlob(snapshotText,graph,blobTypeMap,meta)→newText (lossless: surviving blocks verbatim so Routes/HoldingAreas/stand TaxiwayNode[] survive; ids allocated at write only — survivors reuse meta.origPk/origId, new objects get fresh max+1/negative-OsmId; moved survivor node x/z AND area vertex/body coords patched via _patchNodePosition/_patchAreaPoints; deletes driven by meta.deletedPks/deletedAreaIds (plus three writer-side cleanup drops that need no meta: orphan-runway entries, orphan static items, and the _cascadeOrphanEntries jetway cascade); _renumberTaxiwaySegmentOrdinals rewrites taxiway-segment:<osm>:<ord> suffixes contiguous from 0 after a partial delete so Unity's "non-contiguous ordinal" check passes) + checkpoint-frame reconciliation — _reconcileRuntimeFrames / _reconcileRuntimeSegment prune stale runtime entities from the $$$ GATCARC4 CHECKPOINT FRAME $$$ RuntimeData.$blobdoc.RuntimeEntities. It is generalized over a list of reconcilers {prefix, validKeys, patchMap} (_runtimeReconcilers(siEntries, physPatchMap) builds the combined list), so _reconcilePhysicalRunwayFrames (drop stale physical-runway:* after a runway delete — Unity throws PhysicalRunway: static item … does not exist in CurrentLevel.StaticField.StaticItems — and rename them after a runway rename; plus an addMissing extension that synthesizes a PhysicalRunway runtime entity when a frame has none, sampled from the static entry with a hardcoded `3
src/acl/geo_osm.js geo_data.osm sync (Ground Painter save) — syncGeoDataForLevel(aclPath, newText, …) fits a linear lat/lon↔x/z transform from ≥3 shared graph nodes, then adds/updates <node>s and aeroway=taxiway <way>s in the level's geo_data.osm (the OSM XML the game renders ground from, per Config.geoDataFile) in place — everything else byte-identical, written via .bak. Called from the save-ground-painter-data IPC handler; non-fatal (a failed sync is returned as geoResult and is invisible to the user — main-process log only). NOT re-exported via parser.js; covered by tests/acl/geo_osm.test.js + tests/unit/live-scenery.test.js (transform fitting, OSM add/update, live-scenery freshness)
src/acl/odin/ OdinSerializer binary codec — binary_reader.js / binary_writer.js (binary Odin format), json_reader.js / json_writer.js (Odin JSON text), dotnet_values.js (.NET primitives), binary_entry_types.js (entry type definitions)
Shared Utilities src/utils/constants/ 7 domain sub-modules + barrel index.js: timing.js, fields.js, aviation.js, airlines.js, acl-format.js, map-config.js, ui.js
src/utils/ timeUtils.js (roundSecondsToMinute, minutesToTimeStr, timeToSeconds, timeStringToMinutes), starDisplay.js (STAR/SID display dedup for the approach radar), i18n.js, validators.js (duplicate RunwayTimeline time + empty changes + source-not-active via chronological initialRunways+timeline sweep — 01→19 twice with no reversal is flagged, but 01→19, 04→22 / 19→01 / 01→19 recurrence is allowed + inactive arrival runway at LandingTime (chronological active-set sweep) with a 10-min transition grace (RUNWAY_TRANSITION_GRACE_SEC = 600 s — a runway deactivated within 10 min before landing still counts, so an aircraft already on final when the active set switched is not flagged; covers KJFK_runwaychange 31R/4L landing up to ~9 min after the 18:30 switch, while a landing beyond the grace is still flagged) — reproduces KDCA_peakarrival fuzz 19 @21:15 while active [01,15,22]; the inactive-runway check is skipped entirely when the level has no active-runway source (empty initialRunways + empty timeline), otherwise every arrival would false-flag — same guard in validateFlightObjects (electron/api-server.js)), htmlUtils.js, csvIo.js, zipUtils.js, gamePaths.js (cross-platform game-folder resolution — resolveDataRoot(gameRoot) is the single source of truth for where the data root lives: Windows/Linux <root>/GroundATC_Data vs macOS <root>/GroundATC.app/Contents/Resources/Data (or the .app itself, or …/Resources/GroundATC_Data); bundle-aware, case-insensitive findChild, positive-only cache. Accessors airportsDir/levelsDir/voicesDir/voiceCatalogPath/mainMenuVideosDir/builtinLiveryDir/aircraftProfilesCsvPath/modsDir replace every hardcoded path.join(gameRoot, 'GroundATC_Data', 'StreamingAssets', …); gameRootFromLevelPath(aclPath) derives a root from a .acl; steamCommonDirs() returns per-OS Steam steamapps/common roots for game-root auto-detection; probeForStreamingAssets is the opt-in depth probe (never used by scanGameRoot, so an ancestor can't be mistaken for a root). src/acl/scanner.js and electron/main.js/livery.js all route through it; unit-tested in tests/unit/game-paths.test.js), logger.js, safeHtml.jsx (renders i18n strings w/ allowed HTML tags <strong>, <em>, <br> as safe JSX nodes), debugLog.js (gated debug logging via localStorage + URL query param toggle)

Map Windows (separate BrowserWindow instances):

  • electron/main.js manages groundMapWindows / airMapWindows / flightStripsWindows Maps (keyed by ICAO) + selectedCallSigns Map (synced selection state)

  • Each map window loads the same Vite SPA with query params (?window=groundMap&airport=XXXX, etc.)

  • electron/udp_listener.js listens on 127.0.0.1:20266 for binary aircraft telemetry (10 Hz) and sends commands on 127.0.0.1:20267

  • Live aircraft state pushed to all open map windows (ground + air + flight strips) at 200ms interval via udp-aircraft-state IPC event

  • Map window click-to-select goes through centralized select-aircraft-in-map IPC

  • Radar rendering: AirMapWindow applies display-level STAR/SID dedup (src/utils/starDisplay.js): ZGSZ-style airports name each runway variant separately (e.g. SAREX4.34L/34R/33/16L/16R/15 all the same STAR base) — dedupeStarPathsForDisplay merges them under the base route name (representative longest path + all served runways) for rendering/labels only; underlying per-runway cache keys stay intact for aircraft lookup / game save. Then filterDedupedStarPathsByRunway filters merged groups + preserved per-runway variants by the active runway set. GroundMapWindow computes an auto-fit contentViewBox over the fetched taxiway/stand/runway/area geometry (airports authored far from the world origin like ZGSZ would otherwise show a blank origin-centered box), falling back to GROUND_MAP_CENTER_OFFSET + dataBounds, and snaps the radar view to the airport once geometry arrives.

  • Patch command composer (FlightPatchCommandBar.jsx): when a strip on the approach channel (controlSeat=5) is selected, a command-line-style composer pops up above the strips bar. Click/drag-driven options: Fly Heading (heading-only override — opens a 001–360 slider defaulted to the aircraft's live telemetry heading via atan2(noseDirection.x, noseDirection.z), no fixed value list), Fly Altitude (1000-ft slider, hidden without live telemetry; China Z* airports — isChinaIcao, the same Z-convention flightDefaults uses for the 'zh' language — switch to a 300–2700 m slider in 300-m steps, converted back to feet before Send), Fly Speed (180–240 kt slider, hidden without live telemetry), Fly Waypoint (the aircraft's current-approach waypoint list from the STAR telemetry and the approach cache _starWaypoints, left-to-right in route order entry→IAF; picking a fix sends update_heading at the bearing from the aircraft's LIVE position to the fix, recomputed at Send — byte-identical to the voice pipeline's "fly direct to X"; OVERRIDES a composed Fly Heading, blacking it out), or Clear for Approach (supersedes the whole composed chain). Each panel's Add chains commands; Send dispatches the chain IN ORDER — N sequential send-patch-command frames to the AC27Approach plugin (see the ac27-approach skill). Heading/waypoint frames carry a fixed rate of 3°/s of game time (TURN_RATE_DEG_S — the plugin's update_heading smoothing; for clear_for_appr it drives the bounded de-snap: the nose rotates toward the IAF at that rate until capture, then the game's steering flies the final turn). Gated on checkCommandCapability/checkBepInEx (plugin only exists with BepInEx installed) + witchMode off; Escape/Cancel dismisses. Approach-only: aircraft handed to tower (seat 3) lose the popup automatically

  • Voice command input (live, patch-command vocabulary): useVoiceCommands.js + VoicePTTButton.jsx provide push-to-talk voice commands for the Flight Strips window. Callsign-first flow: spoken airline name → ICAO code (via AIRLINE_CODE_MAP) + spoken numbers → digits → match against UDP aircraft → greedy pattern match against the patch-command vocabulary (heading/altitude/speed/clear-for-approach/fly-direct-to-waypoint, EN + ZH). Speech backend: offline vosk — electron/voice-stt-vosk.js (spawned by electron/voiceSttWorker.js via process.execPath + ELECTRON_RUN_AS_NODE=1), mic capture by sox (bin/sox, default recording device, 16 kHz mono), decoding by koffi + vendored bin/vosk/libvosk.dll (electron/voskFfi.js), grammar-constrained to the patch vocabulary (electron/voice-grammar.json, generated from the live parser tables — EN words + ZH chars), dual EN/ZH models with confidence-winner picking. Fuzzy matching (voiceFuzzy.js + acceptance table) runs as a post-processing safety net. Full docs in references/map-windows.md "Voice Command Input"; scripts/fetch-vosk-model.mjs downloads the models (gitignored); scripts/voice-stt-test.mjs is the TTS round-trip harness. The voice build ships the LARGE en model (vosk-model-en-us-0.22) and the small zh model (vosk-model-small-cn-0.22). Global PTT hotkey (works unfocused): electron/pttShortcut.js (CJS manager — deps injected, no require('electron') — unit-tested in tests/electron/ptt-shortcut.test.js) holds an OS-level globalShortcut registration (default Shift+Space, persisted as pttShortcut in ac27-config.json and re-registered at startup — never cache.json, which Restore All deletes) that broadcasts the hold-to-talk edges global-ptt-down/global-ptt-up to the focused strips window, which starts/stops its useVoiceCommands session on the edge. electron/pttKeyWatch.js (koffi → user32!GetAsyncKeyState, Windows-only, unit-tested in tests/electron/ptt-key-watch.test.js) supplies the key-UP that globalShortcut lacks and the manager absorbs Windows hotkey auto-repeat, so a held hotkey is a true hold; when no key watcher is available it degrades to the legacy global-ptt-toggle. The mic button (VoicePTTButton.jsx) is pointer-capture hold-to-talk too — it releases only on pointer/key-up, never on mouseleave. Remapped from the gear button right of the PTT mic via PttShortcutModal.jsx (title + live key echo only; Backspace disables; must be Modifier+Key or F1–F12 — Windows can't register bare keys); accelerator helpers in src/utils/pttShortcut.js (tested in tests/unit/ptt-shortcut.test.js + tests/components/MapWindows/PttShortcutModal.test.jsx). Save failures log [PTT-SHORTCUT] to the main-process log.

  • Radar/strip windows — mod gate (browser): the Surface Radar / Approach Radar / Flight Strips toggles in the level browser render only on Windows (get-system-info.platform === 'win32', and not in demo mode). The Debug Mode settings item and the help-overlay rows for these BepInEx-backed features (the cards section + the Debug Mode settings row, flagged modOnly in BrowserHelpOverlay) use the same Windows gate (isWindows), so mac/linux builds hide them too. Clicking one shows a modding-risk warning ("This feature require modding the game…") with a "do not show again" checkbox persisted as the cache flag radarModWarnDismissed (new key → CACHE_VERSION bumped). With Debug Mode off the dialog adds "Shut down the game and enable debug mode in setting." and never installs; with Debug Mode on, agreeing installs. The check is checkCommandCapability() — the deployed DLL's MD5 vs the release reference (bundled Workshop DLL on Steam/Workshop via resolveWorkshopBundledDllPath(); R2 object ETag on normal/voice builds). In place + matching → the window opens with no install; missing, or a packaged mismatch → downloadApproachDll() (R2 on normal builds, the bundled path on Steam/Workshop — never R2) then installApproachDll(), with a file-picker fallback. Dev (!app.isPackaged) never enforces the MD5, so npm start / npm start steam don't nag. The Flight Strips window no longer re-runs checkCommandCapability and has no install/update overlay (commandCapable is treated as true — the browser already guaranteed the mod); the legacy ApproachPluginInstallOverlay / handleLoadDll / STORAGE_KEY_APPROACH_PROMPT prompt code is now unreachable.

  • Level reset: BrowserScreen.jsx header has Restore All (browser_restore_all, MdOutlineRestore) as a top-level button next to Livery. Click shows warning modal (restore_all_title/restore_all_warning/restore_all_warning_detail — "it will remove all level files and backups, need to go to Steam to restore"). Confirm calls reset-all-levels IPC (electron/main.js:reset-all-levels, electron/preload.js:resetAllLevels(rootPath)) which deletes every GroundATC_Data/StreamingAssets/Airports/XXXX/Levels entry (files + subdirs, including .bak) FIRST and only then removes cache.json (stale STAR/SID/approach caches, ground anchors, file lists). gameRoot resolves as explicit rootPath arg (renderer store) → fallback cache.json gameRoot → NO_GAME_ROOT; the explicit arg keeps restore working when the cache is missing/lang-only and keeps retries working after cache.json was already deleted. Then shows second modal with Steam verify steps (restore_all_success_detail — Steam Library → right-click Airport Control 27 → Properties → Installed Files → Verify integrity). OK on the success modal quits the editor (electronAPI.quitApp → app-quit IPC → app.quit()), since the user's next step is Steam Verify Integrity; also reached from the post-update nudge in App.jsx. Browser cache cleared via setBrowserCache({}, {}) + refreshKey. Help: browser_help_restore_all, BUTTONS.restoreAll. i18n: browser_restore_all*, restore_all_* (zh+en).

  • Collapsible airport cards (level browser): each .airport-card header is a click toggle (chevron + aria-expanded) that shows/hides its .level-rows (the header radar toggle buttons — BROWSER_RADAR_TOGGLES_ENABLED = true, Windows-only — stopPropagation so they never collapse the card). A one-shot auto-collapse fit pass (useLayoutEffect in BrowserScreen.jsx) measures real card/header heights and collapses trailing airports (last first) until the total fits .browser-content, leaving every airport header visible. It runs once per app session — guarded by browserAutoCollapseDone in appStore — and never re-runs on resize, navigation, or user toggles; explicit user toggles are excluded from the fit pass. Collapse state is session-persisted in the store (browserCollapsedAirports = explicit user prefs, browserAutoCollapsed = fit result) so leaving for a level and returning restores the user's chosen layout; setRootPath clears all three flags. i18n: browser_airport_expand/browser_airport_collapse (zh+en).

  • Post-update nudge: electron/updater.js writes post_update_pending.json in userData inside installUpdate (before app.quit(); dry-run gated by AC27_UPDATE_MARK_PENDING=1); electron/main.js exposes check-post-update-pending/clear-post-update-pending (electron/preload.js); src/App.jsx after boot (booting false, not prompt/downloading/installing, no modal open) checks once via checkPostUpdatePending and shows one-time modal update_post_title/update_post_body (AC27Editor已更新,若任意存档曾经被修改,请还原所有关卡文件。 / AC27Editor Updated, if any level file was modified, please restore all level files.) with Cancel (modal_btn_cancel) | Continue (update_post_continue). Cancel clears flag; Continue clears flag and opens the same restore-all modal. Flag cleared on either path so nudge is one-shot.

  • Ground Painter: dedicated full-screen window to edit a level's static scenery (taxiways, runways, areas, stands) against an id-free Graph. Entry: EditorScreen toolbar button (IoPencil, i18n toolbar_ground_painter) → store toggleGroundPainter → showGroundPainter portal (src/components/EditorScreen/GroundPainter/ — GroundPainter.jsx+.css, GroundPainterToolbar.jsx, snap.js, fillet.js, metrics.js). Read path src/acl/scenery_graph.js:buildSceneryGraph runs in the main process (load-ground-painter-data IPC, because the renderer can't reliably dynamic-import the CJS acl module); write path src/acl/scenery_write.js:patchSceneryBlob via save-ground-painter-data IPC (.bak + writeAcl, returns the new baseline). Edits are in-memory only until Save (confirm prompt, .bak checkbox default on) / Cancel (discard); the painter renders as a full-screen fixed overlay (z-index:9999) that covers the editor UI — so on-screen flight/timeline interaction is visually blocked, but the MCP API is not gated (create_flights/modify_flights still run); depth-1 Ctrl+Z via paired groundPainterHistory/groundPainterMetaHistory (appStore.js:setGroundPainterGraphWithMeta). Snapshot is only currentPath (a per-level subset — see the v5 note), so no cache invalidation is needed. MCP provides full toolbar coverage in electron/api-server.js (documented in ac27-editor-mcp/SKILL.md §10 — all tools push paired history, deduplicate nodes, and record meta.deletedPks/deletedAreaIds so saves persist): get_ground_painter_state (read), create_taxiway_lines (taxiwayLine D), create_taxiway_fillet (curve F — connected + virtual, radius 0.5..5.0, ghost repair), create_runways (runwayLine R — heading-derived names + Flags=4 pavement strip + meta.runwayPavement coupling), create_areas/create_area (area G — 0|1|2, bulk+single), create_stands (stand H — nose+tail, heading 1..360), delete_ground_objects (point-priority stand>segment/runway>area, orphan GC), undo_ground_painter (paired graph+meta). Runway end names (the designators on each threshold, e.g. 27/09 or 27L) are edited only via floating on-canvas text boxes (runwayOverlay in GroundPainter.jsx) — not the toolbar — each a plain 3-char type="text" input (maxLength={3}) with no in-place validation, so a box can be cleared to empty while typing; the DD/DD[A-Z] format (two digits, optionally one capital letter) is validated on Save (invalid names block the save and show ground_painter_validation_runway_name via setGpError). The toolbar has no runway end-name editors. The global keydown handler ignores Delete/Backspace/Escape/Ctrl+Z whenever focus is in a text/edit field (isTextEditTarget in GroundPainter.jsx), so typing or clearing a name box never deletes the selected runway. Runway-vs-pavement selection: in the Select tool, taxiway-segment strips whose Name === a runway's physicalName (ZSJN runway 01/19 ↔ strips named "01/19") are skipped from the segment candidates — a click on the runway always selects the runway, while connector taxiways that just meet it (A, B, C…) stay selectable. A normal editor save after a Ground Painter save does not revert scenery — the flight-save pipeline re-reads the file from disk and replaces only the flight-plan entry range (the editor's in-memory vals stay stale until the level is re-entered). Full internals in references/acl-format.md "Ground Painter — id-free Scenery Graph".

  • Live scenery (in-memory only, per-file): Stand/Runway/Airway dropdowns are never read from cache.json — they come from electron/main.js:liveSceneryCache (Map keyed by absolute aclPath, {icao, mtimeMs, vals}, mtime-checked via _isLiveCacheFresh). Helpers: _collectLiveSceneryForFile (reads the open .acl via readAclText + _parseStandPositions/extractV4RunwayPairs+buildSceneryGraph/extractStarRunwayMappings etc.), _getOrComputeLiveScenery, _unionLiveEntries (merge for collect-values ICAO-only callers), _buildCollectValuesBase (strips live keys from airportCache). IPC: get-live-values/invalidate-live-values (preload getLiveValues/invalidateLiveValues); collect-values and scan-runway-pairs delegate to the live union and return [] when no live entry (not stale disk data). init-airport-cache/refresh-root-scan and their _writeCache persist blocks strip Stand/Runway/Airway (and standPositions); save-ground-painter-data does _invalidateLiveCache+_getOrComputeLiveScenery after writeAcl. Renderer: EditorScreen.jsx mounts via getLiveValues(filePath, icao) with collectValues fallback; GroundPainter.jsx post-save refreshes via getLiveValues. Stand rename of a newly-created stand (identifier field) is honored by scenery_write.js so the dropdown Identifier matches the typed name. _buildCollectValuesBase additionally injects _gameCallsigns — the install-global flight-number pool, i.e. the union of every airport's _flightNums (src/utils/gameCallsigns.js:unionFlightNums, computed fresh). catalog.bin is deliberately NOT the source: an in-game stress test writing all 585 catalog callsigns proved it over-reports (KLM982/KLM983/KLM984 crash) and is voice/bundle scoped (CCA9052 only allocates for a Chinese-captain voice); the shipped union is every-callsign-proven. The Flight # picker (FlightTable.jsx) unions it with _flightNums[airline], while AirlineCode and its whitelist stay per-airport (a foreign airline fails callsign allocation in-game even with a recorded number) — see references/data-flow.md.

  • Realtime flight import (aviationstack) — DISABLED, kept for revival: a toolbar button (left of the AI button) + RealtimeImportModal (src/components/EditorScreen/RealtimeImport/) that fetched real flights and replaced the schedule. Gated by REALTIME_IMPORT_ENABLED = false in EditorScreen.jsx — the button and modal do not render; flip the constant to re-enable. Why disabled: the aviationstack free plan can't filter by time-of-day (arr_scheduled_time_arr / arr_scheduled_time_dep return function_access_restricted; they're date-only anyway), caps each request at limit=100 (KJFK ≈ 50 arrival / 38 departure pages), and returns local wall-clock times tagged with a nominal +00:00 — so they must NOT be timezone-converted (isoToLocalHHMM reads the clock substring directly). Wiring that remains in place: main-process electron/aviationstack.js (plain-HTTP http.get — free plan rejects HTTPS — with in-band {error:{code}} mapping), aviationstack-fetch + validate-flights IPC in electron/main.js (the latter overlays live vals onto the MCP buildConstraints/validateFlightObjects path), aviationstackKey in ac27-config.json (get-config whitelist, same semantics as the cloud-LLM vendor keys), and the pure mapper src/utils/realtime/aviationstack.js (buildFlightsFromAviationstack: airline/aircraft-type/registration intersection against live vals, codeshares kept, first-valid-wins dedupe on a physical-flight key (direction+route+scheduled) plus kept callsigns, and retiming of an out-of-window batch into [configStartTime, configEndTime] preserving order — no time drops). Live values gained _designatorToType (ICAO designator → full AircraftType) in _buildCollectValuesBase for aircraft.icao mapping. i18n realtime_* (zh+en). Tests never hit the network: tests/electron/aviationstack.test.js stubs http, tests/unit/realtime-aviationstack.test.js is pure, and tests/unit/realtime-kjfk-fixture.test.js replays the saved tests/fixtures/aviationstack-kjfk.json.

  • Live weather import (aviationweather.gov, toolbar right of AI): TiWeatherPartlySunny button (live_weather_title 实时天气/Live Weather, tooltip tooltip_liveWeather) wired via the useLiveWeatherImport hook — one click replaces BOTH timelines from past-24h METAR history (/api/data/metar?ids=XXXX&format=json&hours=24, TAF fallback for stations like ZSJN that return 204 with no METAR). Main-process electron/live-metar.js (the API has no CORS, so the renderer cannot fetch directly; custom User-Agent, 5-min cache) + fetch-live-metar IPC in electron/main.js (electron/preload.js:fetchLiveMetar); pure mapper src/utils/realtime/metar.js (metarToPreset: precip→AfterRain, OVC/VV→OvercastSky, BKN→MidCloudy, SCT→PartlyCloudy, FEW→FewCloudy, clear→Sunny, vis<3SM→OvercastSky; obs clocks converted to the airport-local tz via AIRPORT_TIMEZONES). Weather frames span the full day with consecutive-dupe collapse; wind frames are an explicit 15-min grid across [configStartTime, configEndTime] (forward-filled from the latest obs at/before each tick, identical values repeated — ~hourly METAR cadence would otherwise collapse to a single frame). i18n tl_live_* toasts + live_weather_title (zh+en). Tests: tests/unit/realtime-metar.test.js (pure, captured fixtures), tests/electron/live-metar.test.js (mocked https), tests/integration/live-metar-api.test.js (real endpoint, gated on AC27_LIVE_API=1, skipped otherwise).

  • Ground Painter area-type toolbar toggle (no dropdown): when the Area/polygon tool is active, GroundPainterToolbar.jsx shows a 3-button toggle popover (.gp-areatype-group) that pops up above the highlighted polygon button (.gp-area-wrap wrapper). Buttons: PiSelectionThin → boundary (ground_painter_area_boundary, 边界/Perimeter), FaParking → apron (ground_painter_area_apron, 停机坪/Apron), FaBuilding → building (ground_painter_area_building, 建筑/Building). Only one is active at a time; the default areaType in GroundPainter.jsx is 2 (Building) (useState(2)). Clicking a toggle calls onAreaType(value); the popover renders only while tool === 'area'.

  • Ground Painter stand icon & select priority: Stand plane icon is fixed on the map (world size baseDiag*0.028/2.4 ≈ 0.522 GU for ZSJN — the size it had at 240% zoom), not screen-constant (getDynamicSelectThresholds, GroundPainter.jsx:30-43; render/hit-test use fixedWorldSize/512 and fixedWorldSize/2 at :2948/:3240). Select hit-test is dynamic: getDynamicSelectThresholds(viewBox,baseVB) computes scale=vbDiag/baseDiag (<1 when zoomed in) and TH=max(worldSnapDist(vbDiag),0.45)*scale, TH_STAND=fixedIconHalf*1.35*scale, tightStand=fixedIconHalf*0.75*scale — so the world/screen area covered by the snap shrinks when zoomed in, freeing the apron/building under a stand. **Stand has higher priority than taxiway**: bestStand || bestLine (stand wins over segment/runway when both in range); area vs stand uses tightStand — interior area wins if standDist>tightStand, otherwise precise stand wins. **Stand does not snap** (GroundPainter.jsx:1122 and the placement/drag paths): TOOL_STAND preview (onMove early setWorld(raw)), placement (onClick nose=raw) and body drag (applyDrag dx=raw-startWorld early return) all use raw cursor, not findSnap, so moving a stand is free.

  • Ground Painter box-select (multi-select) + select icon swap + right-click cancel: GroundPainterToolbar.jsx select button uses FaArrowPointer (react-icons/fa6, ground_painter_tool_select) and the Box Select button uses PiSelectionDuotone (多选, ground_painter_tool_select_all/ground_painter_tool_box_select) button immediately to its right (tool==='boxSelect', TOOL_BOX_SELECT='boxSelect' in GroundPainter.jsx). Box-select drags a world-space rectangle (translucent #6aa0ff 6 4 dashed rect with HL_SW stroke, boxRect={x0,z0,x1,z1}, boxDragRef, onMove box priority, onMouseDown box start vs group-move, onMouseUp → computeBoxSelection/computeAirBoxSelection → multiSelected=[] — helper pointInBox/computeBoxSelection now counts any vertex inside (segment/runway/area: some not every; runway: either threshold; was fully-enclosed) so a large object is grabbed by enclosing any corner, plus areas ARE selectable (same rule: any vertex inside). Air mode uses computeAirBoxSelection — only airwayNode hits (pointInBox on fix; procedures hoverable/selectable via click/shift but box marquee stays node-only); applyBoxClick is airway-aware and populates multiSelected while syncing selectedAirwayNode (Select tool keeps its own selectedAirwayNode state). multiSelected:[{kind,idx}] lives beside single selected (useState([]) + boxRect state), SelOutline renders selected + multiSelected together, stand/airway icon isSel checks isMultiSelected, deleteSelected aggregates segIdxs/rwIdxs/standIdxs/areaIdxs/airNodeIdxs descending with allDelNodes GC via doOrphanGC + air cascade (degenerate <=1 procedure drop + node reindex). Group body drag (dragRef kind==='multi', free raw-startWorld translate of origNodes + origAirwayNodes set) and node-grab within multi are handled; middle-button still pans. Node/endpoint grab precedes the body-drag fallback — in onMouseDown the grab loop runs even when onMulti is true, because pointOnMultiSelected treats any point on a runway/segment line (including both endpoints) as "on selection"; with the old if (!onMulti) gate a runway/segment endpoint could never be dragged in box-select mode (the whole body moved instead). Regression: tests/components/EditorScreen/GroundPainter/runwayEndpointDrag.test.jsx. Drags own the pointer: any left/middle mousedown on the map sets a dragging state that adds .gp-overlays--dragging to the floating-overlay container, whose pointer-events: none !important rule stops the runway end-name boxes (and the stand/segment/rename panels) from intercepting the mousemove/mouseup — without it a threshold drag that reaches an end-name box pauses and loses its mouseup. The map SVG also has user-select: none (GroundPainter.css) so a drag never highlights the length/angle labels. Right-click (button===2) cancels all selection — onMouseDown early button===2 preventDefault() clears selected/multiSelected/boxRect/dragRef/boxDragRef, svg has onContextMenu={e=>e.preventDefault()} to suppress the browser menu. Escape also clears boxRect/multiSelected before selected. Toolbar onDeselect/onDelete disabled now checks selected || multiSelected.length. i18n ground_painter_tool_select_all = 多选/Box Select.

  • Ground Painter taxiway → select single-click cancel: In taxiwayLine/taxiwayCurve (fillet) with an in-progress draft (committing one point or filletPicks 1/2), a single click on either select button (FaArrowPointer onToggleSelect) or multi-select (PiSelectionDuotone onTool('boxSelect')) immediately cancels the edit and switches mode — onToggleSelect checks tool!==TOOL_SELECT then cancelPlacementInput(); setTool(TOOL_SELECT); setSelectEnabled(true), onTool('boxSelect') already did cancelPlacementInput() via GroundPainterToolbar onTool={(id)=>{cancelPlacementInput(); setTool(id)}}; the lastToolRef effect if(lastToolRef.current!==tool) cancelPlacementInput() is the backup. cancelPlacementInput clears filletPicks/committing/world/boxRect/boxDragRef.

  • Ground Painter fillet — disconnected (virtual) fillet is additive, never deletes taxiway: The virtual path is additive: applyVirtualFillet(g,m,res,segIdxA,segIdxB) + attachVirtualFilletLeg (fillet.js) wire each picked segment to its tangent point without removing anything — (a) a node already sits at the tangent point (endpoint, or interior node of a multi-vertex polyline → split at that node so the branch is a real junction); (b) tangent beyond the near endpoint (the common gap case) → original segment untouched + extension stub [T→near] bridges the gap (original PK survives verbatim); (c) tangent strictly inside the span → segment split at the tangent point into [near..T]+[T..far] (original PK ghost-deleted, geometry fully preserved by the pieces, arc branches at T). The arc is appended with its endpoints anchored at the (possibly pre-existing) tangent nodes, so only interior arc points become new nodes. Connected fillets keep the truncation semantics (the corner region IS the rounding). computeFillet returns ground_painter_fillet_error_parallel for parallel/collinear-disjoint picks, and commitFillet early-returns on !res.ok. Preview draws dashed tangent→near connection legs for virtual picks instead of the truncated far→T legs. Tests: tests/components/EditorScreen/GroundPainter/fillet-virtual.test.js (+ fillet-connected.test.js for the connected/truncation path). The component-level commitFillet for a T junction (deg>2) keeps the O-T stubs for the third arm; parentOsmA/parentOsmB are hoisted out of the connected-leg else block so the stub bookkeeping can read them (a const declared in the sibling block threw ReferenceError: parentOsmA is not defined). Regression: tests/components/EditorScreen/GroundPainter/GroundPainter.test.jsx (T junction commits without error and keeps meta.segOrigPk aligned).

  • Ground Painter — ghost-node invariant: a NEW entity must never reference a node that will not be written: a node whose original PK was pushed into meta.deletedPks is a ghost — deliberately kept in graph.nodes so every other entity's index stays stable, but dropped from the .acl by patchSceneryBlob. A new entity (one the writer re-synthesizes, i.e. meta.segOrigPk/standOrigPk/runwayOrigPk is null) that still points at a ghost index serializes to "$iref:null", which the Odin JSON reader rejects (invalid $iref payload "null") and which aborts the whole Ground Painter save. fillet.js exports ghostNodeIndices(graph, meta) (the ghost index set) and repairGhostRefs(graph, meta): the latter re-points new segments/stands/runways onto a live node at the same coordinate (duplicate nodes at a snap point are geometrically identical, so the shape is preserved) and drops entities that cannot be repaired — survivor entities are deliberately never touched, because the writer copies them verbatim and editing their indices would silently not persist. commitFillet calls it right after ghost-deleting the O nodes (at a T junction the arms often use co-located twins), and save() calls it again as a guard — a repair there is logged with console.error because it means an earlier edit left the graph inconsistent. Returns {remapped, dropped, warnings} where warnings are i18n keys. The writer is the second half of the defence: _synthesizeSegment/_synthesizeStand return null (caller drops the entity) instead of emitting a null node ref, and new segments/stands whose endpoint nodes no longer resolve are skipped with a warning. Covered by tests/integration/ghost_ref_invariant.test.js.

  • Ground Painter — survivor dangling-$iref gate + last-resort validation: deleting a taxiway node in the painter drops it from the .acl, but survivor entries are copied verbatim — so a survivor taxiway-segment/stand still holding a $iref to it serialized an unresolvable reference that the game's TaxiwaySegment2DFactory null-derefs on level init (the ZSJN_leisure_1 crash: $iref:2004/$iref:2040). patchSceneryBlob now runs _gateSurvivorDanglingRefs before the jetway cascade, repairing each survivor in order: (1) rewire the dead ref to a live taxiway-node at the same coordinate (co-located junction twins are geometrically identical); (2) excise the ref from the entry's $rcontent list when ≥2 refs remain — _exciseIrefFromRcontent also decrements that list's $rlength (the last "$rlength" before the "$rcontent" key), and returns null for a ref that is a bare property (a stand's NosePosition/TailPosition), which falls through to the drop path; (3) drop the whole entry — its ids join deletedIds first, so a dropped stand's jetways cascade too. A rewire that would collapse a polyline onto a single node (a degenerate self-loop the game's edge validator refuses) also forces the drop. After serialization a final validation pass (≤16 iterations to fixpoint) drops any remaining crash-class dangling taxiway-segment/stand entry and reports every other dangling owner (taxi-navigation, airways, runtime) rather than dropping it, then renumbers taxiway ordinals if anything was dropped. This pass also runs on a file that was already corrupt before this save: a pre-existing crash-class dangling count defeats the lossless no-op early-return so the offending entries get dropped instead of re-committed verbatim. Covered by tests/integration/survivor_ref_gate.test.js (reads a real game level via tests/helpers/gameRoot.cjs (ESM facade gameRoot.js) — skips cleanly when the game is not installed at AC27_GAME_ROOT). Triage scripts for this class: tests/integration/scan_dangling_refs.cjs (every dangling $iref grouped by owning PK and section, --diff a.acl b.acl), scan_taxiway_health.cjs (walks taxiway-segment Nodes lists the way TaxiwaySegment2DFactory does — flags dangling ids, $iref:null, non-node targets, positionless nodes) and scan_level_health.cjs (per-level entity + dangling-count table).

  • Ground Painter — non-fatal save warnings are surfaced, not swallowed: patchSceneryBlob(snapshotText, graph, blobTypeMap, meta, opts?) takes an optional opts.warnings array; the save-ground-painter-data IPC handler passes one and returns it alongside newText/geoResult (also logged in the main process). A warning is structured { key, params, text } — the writer runs in the main process where no translation context exists, so key + params drive renderer-side i18n (writerWarningText in GroundPainter.jsx; text is the plain-English fallback for console logs and any key the dictionary does not cover). A save that succeeds but dropped geometry now shows a ground_painter_saved_with_warnings modal listing the warnings (and still installs the new baseline) instead of silently losing it. 17 new i18n keys (zh + en in src/utils/i18n.js) back this: ground_painter_fillet_error_click_near, ..._fillet_tool_hint, ..._fillet_dropped_leg|_stand|_runway, ..._saved_with_warnings, ..._save_failed, ..._error_distinct_endpoints, ..._error_area_min_vertices, and the eight ground_painter_writer_* keys (gate_rewired, gate_excised, gate_dropped_collapse, gate_dropped_unrepairable, new_segment_dropped, new_stand_dropped, last_resort_dropped, dangling_report).

  • Ground Painter background image + opacity: GroundPainter.jsx imports any raster image as a map-anchored reference layer behind the scenery (bgImage state {src,naturalW,naturalH,anchorX,anchorZ,baseW,baseH,scale,offsetX,offsetY,opacity}, setBgImage/updateBgImage). Image is auto-fit to the current baseVB||viewBox (fitScale = min(fw/naturalW, fh/naturalH), anchor = viewBox center) with scale:1, offsetX:0, offsetY:0, opacity:0.6 on import (handleImportImage FileReader→dataUrl→probe Image). Placement: rendered in world/SVG coords (<image href x/y width/height preserveAspectRatio="none" opacity={bgImage.opacity ?? 0.6}/>) so it stays glued to the map while panning/zooming — center = anchor + (offset%/100 * scaledW/H). Controls live in GroundPainterToolbar.jsx gp-bg-panel (toggled by IoImageOutline bgPanelOpen): import/remove/reset (clearBgImage nulled, resetBgImage → scale 1/offset 0/opacity 0.6) + four sliders — L/R (offsetX -100..100%), U/D (offsetY -100..100%), Scale (10..500%, stored scale 0.1..5), Opacity (0..100%, stored opacity 0..1, default 60%, Math.round(opacity*100)% readout, onBgOpacity(v/100)). Panel is position:absolute bottom:100%+8px right:12px with head/close/import/row/ctrl styles in GroundPainter.css (.gp-bg-panel, .gp-bg-ctrl). The placed image persists as a <file>.acl.bg.json sidecar written by the save-ground-painter-data IPC handler and is restored on reopen against the frozen _groundAnchor (electron/main.js). i18n ground_painter_bg_* (toggle/title/import/remove/offset_x/offset_y/scale/opacity/reset zh: 背景图/导入背景图/移除/左右/上下/缩放/透明度/重置, en: Background image/.../L/R/U/D/Scale/Opacity/Reset).

  • Ground Painter — polygon simplicity guard (Triangulator): the game triangulates every area polygon and hard-fails the level load on a self-crossing outline (ConstraintEdges... intersect! → NullReferenceException). src/acl/scenery_graph.js:polygonIsSimple(points) (and the ESM mirror src/components/EditorScreen/GroundPainter/polygon_simple.js for the renderer, which cannot import the CJS acl module) rejects bowties: _segProperCross with bbox pre-filter, adjacent-edge sharing exempt, degenerate edges ignored. GroundPainter.jsx freezes a vertex drag at its last valid position when !polygonIsSimple(newPoints); electron/api-server.js:create_areas validates every polygon the same way and returns {success:false, error:'area outline crosses itself'}.

  • Ground Painter — degenerate-edge guards: the writer encodes vertices by coordinate — two consecutive positions closer than 1e-4 GU collapse into one vertex and the segment "joins vertex X to itself", which the save-time integrity check refuses. Guards: (1) GroundPainter.jsx:applyDrag node mode scans every polyline containing the dragged node and freezes the drag if any consecutive pair would colocate; (2) fillet.js:attachVirtualFilletLeg dedups the tangent polyline and drops a leg that would be <2 distinct positions, applyVirtualFillet pre-checks both legs anchoring at the same position (or same shared-position node) and rejects before mutating, and repairGhostRefs final sweep drops degenerate segments (self-loop or consecutive dup); (3) scenery_write.js:patchSceneryBlob referenced-node rescue keeps any taxiway-node still $iref'd by a surviving runway/taxiway-segment/stand/taxi-navigation (3-pass fixpoint), then _gateSurvivorDanglingRefs now covers runway thresholds too (unrepairable threshold drops the runway + its pavement strips via droppedRunwayPhys), and the PhysicalRunway registry + pavement-strip suppression removes orphan paint after a gate-dropped runway. (4) The delete-time orphan GC dedups its candidate indices (Set, both api-server.js and GroundPainter.jsx): a runway's candidates include its thresholds AND every pavement-strip node, and those overlap — splicing a duplicated index twice over-decrements indices and collapses a segment to a self-loop. The writer also drops any NEW segment whose consecutive node ids repeat.

  • Ground Painter — runway/stand delete cascade + flight reconciliation: a runway IS its pavement strips (Name === physicalName, Flags=4) + thresholds — GroundPainter.jsx:deleteSelected now cascades through cascadeDeleteRunway (records meta.deletedPks for strips + deletedRunwayNames for end/physical names, GCs strip + threshold nodes) and recordDeletedStand (records deletedStandNames), both for single and multi-select (box-select). electron/api-server.js:delete_ground_objects mirrors the cascade with orphan GC and deleted*Names bookkeeping; move_ground_objects/move_ground_endpoint/rename_ground_object (new MCP tools) move/rename segments/runways/stands through the same graph (runway moves carry coupled strips, renames validate ^[0-9]{1,2}[A-Z]?$ and uniqueness). create_runways now suffix-deduplicates heading-derived designations (L/R then C) against taken physicalName/end names so two runways never synthesize duplicate $k: runway:<name> keys ("found 0 named runways"). Flight reconciliation in electron/main.js:save-ground-painter-data after writeAcl: compares the saved file's stands/runways against the pre-save snapshot (truth = saved file, not graph — the writer may drop more than the graph shows), builds runwayRename/standRename maps (index-parallel runwayOrigInfo/runwayOrigPk + nose-position matching for stands, guarding that rename targets still exist in the saved file), remaps flights, purges any flight whose Stand/Runway existed pre-save but no longer resolves, rebuilds flight sections via generateFullAcl (with approach cache + exportGameCSV sync), and returns purgedFlights/refsRemapped alongside newText (when flight sections were rebuilt, finalText = readAclText(filePath) so the renderer's snapshot reflects the purge). Box-select/group-move thresholds use move_ground_objects. Runway rename MUST cascade to graph.procedures[].runwayName (and to the directional entries/exits): scenery_write.js:_buildRoutesWrapperForPatch rebuilds each runway's Routes[] from graph.procedures grouped by runwayName === <runway PK suffix>, so a rename that updated only runways[].names/physicalName/strips left every procedure's runwayName stale → they matched no runway and the whole Routes list was rewritten away (ZSJN_runwaychange: 25 routes → 14, all 8 STARs gone, flights kept "STAR": "PANK8X" on a now-route-less runway → ContextCross.Services.RuntimeAircraftSpawnService.SpawnFlyApproachingAircraft NullReferenceException at level load). Both rename paths cascade: GroundPainter.jsx:updateSelectedRunwayNames and electron/api-server.js:rename_ground_object (the MCP tool also gained the entries/exits remap for parity). Second half of the fix: the flight reconciliation now also purges a leg whose Airway (STAR) existed pre-save but is gone from the saved file (reason STAR procedure removed or renamed away; purgedFlights entries carry STAR), alongside the existing Stand/Runway purges — so deleting a runway/procedure can never leave a crashing arrival behind. The STAR check is per runway, not name-global: the game resolves FlightPlan.STAR among the routes of the flight's OWN runway, so a STAR that survives on another runway is not enough (KJFK_runwaychange: the air fuzz renamed SIE.CAMRM5@4L, leaving SIE.CAMRM5@4R/@15L; 9 flights on 4L were purged with reason STAR variant removed or renamed away for this runway). Both checks only fire for a pair that was resolvable pre-save, so authored junk is left alone. Regression: tests/integration/scenery_runway_rename_routes.test.js (writer: rename + procedure remap keeps all routes; without it they are orphaned) and the rename-cascade case in tests/electron/api-server-air-functional.test.js.

  • Ground Painter — runway synthesis on shipped v5 levels (per-scope type sampling + optional IsActive): _sampleRunwayShapes resolves any still-null sub-object $type from the PK-blobdoc-scoped type table (_documentTypesByName; patchSceneryBlob slices ranges.bd and threads it through _synthesizeNew → _sampleRunwayShapes). Type ids are per-scope: a document-wide scan maps the same number to different types in another $blobdoc/the checkpoint frame (e.g. 29 is List<IStaticEntity> in the level blobdoc but ReactiveProperty<bool> in the checkpoint frame), so only the PK-blobdoc scope is valid. IsActive (ReactiveProperty<bool>) is optional — shipped v5 runways omit it and the PK blobdoc does not declare the type, so a synthesized runway omits the field; _assertSampledType still guards the required geometry types (the fixture's no fallback allowed assert stays green because that file genuinely lacks PhysicalRunwayStaticItem). Fixes the ground-fuzz cannot determine type "ReactiveProperty<bool>" from the runway save abort. Regression: tests/integration/runway_entry_type_id.test.js (game-root-gated v5 case).

  • Ground Painter — renamed/dropped runway pavement-strip cascade: when _gateSurvivorDanglingRefs drops a renamed survivor runway (its thresholds went dangling), it records the entry's pre-rename PhysicalName, while the coupled pavement strips (survivor strips renamed in the graph, and synthesized fillet pieces) carry the post-rename designation. _cancelRunwayRegistryForDrops(droppedRunwayPhys, physPatchMap, orphanSiPks) cancels the registry rename, orphans BOTH the old and post-rename physical-runway:* keys, and expands droppedRunwayPhys along the old→new mapping (worklist handles A→B→C chains) so the name-based strip suppression (pkOutSynth.filter(...) against droppedRunwayPhys) drops both names. Without it the save emits saved file has pavement strips without a runway: 23/25R (KJFK) / 7/22C (KDCA). Regression: tests/integration/runway_rename_drop_pavement.test.js. The fuzz harness also now returns the summary shape on the inline .gp-error refusal (was a faceless undefined target) and reports the offending runways + strip details.

  • Ground Painter — orphan-GC index dedup (self-loop refusal): deleting a runway feeds doOrphanGC its two threshold indices and every pavement-strip node — but the strips' end nodes ARE the thresholds (consecutive strips also share endpoints), so the candidate list contains duplicates. The GC (electron/api-server.js:doOrphanGC for MCP, GroundPainter.jsx:doOrphanGC for the UI) must collect orphans in a Set before splice-descending: splicing the same index twice deletes a second, unrelated node and over-decrements every index above it, collapsing a nearby segment's endpoints onto one index → a zero-length self-loop (_validateNoDegenerateEdges refuses: taxiway-segment … joins vertex '…' to itself, no file written). Defence in depth: scenery_write.js:_synthesizeNew drops any NEW segment whose consecutive resolved node ids repeat (same policy as the null-ref drop, ground_painter_writer_new_segment_dropped warning). Regression: tests/electron/api-server-air-functional.test.js (runway delete with overlapping threshold/strip nodes → no self-loop).

  • Ground Painter — stale taxi-navigation cascade on stand deletion: deleting a stand drops its jetway:* static item and checkpoint-frame runtime entity, but its taxi-navigation:stand/:pushback points can survive — a pushback point references a LIVE taxiway node, so the dead-$iref cascade skips it, yet its RelatedStand then names a stand that no longer exists and the game NREs building the taxi-navigation/pushback graph. _cascadeOrphanEntries(pkEntries, siEntries, deadIds, liveStandIdents) now also drops a taxi-navigation entry whose non-empty RelatedStand is absent from the live stand identifiers (built from each graph.stands[].identifier/.name), while keeping the single CrossTaxiwayNames declarer (rewired by the gate) and entries with an empty RelatedStand (runway entry/exit points). Regression: tests/integration/taxinav_stale_stand.test.js.

  • Ground Painter — embedded RunwayTimeline dead-reference pruning: a scenery save can delete/rename-away a runway the level's embedded RunwayTimeline still activates; Unity resolves InitialRunways at load and throws NullReferenceException on a name with no runway: entry (ZSJN_leisure_2 ground fuzz left InitialRunways: ["19"] after the 01/19 runway was gone), and the AC27 playtest throws ArgumentException: runway.initialRunways references runway '01' that does not exist in the loaded airport. Parameter name: timeline (ZSJN_runwaychange ground-editor "delete everything"). patchSceneryBlob calls _pruneRunwayTimelineReferences(text, liveRunwayEnds) — with liveRunwayEnds = _liveRunwayEndsFromGraph(graph) computed once before the no-op check so it runs on both the rebuild path and the lossless no-op early-return (self-heal of a pre-fix save). It drops every dead end from InitialRunways (_pruneInitialRunwaysText) and prunes the Timeline change frames (_pruneRunwayTimelineFrames: drop a RunwayChange whose Source/Dest no longer resolve, drop a RunwayChangeFrame whose Changes empties, recompute every $rlength via _setArrayRcontent). When some live ends remain but the list would empty, it seeds the first live end; when the painter removed every runway, it leaves InitialRunways/Timeline empty instead of fabricating one. Conversely _ensureInitialRunwaysContain(text, _newRunwayEndsFromMeta(graph, mm)) auto-adds every painter-CREATED runway's primary end (meta.runwayOrigPk[i] == null) to InitialRunways, so a newly-drawn runway is active. Zero-runway saves are refused: the Ground Painter save() shows ground_painter_validation_runway_required before the IPC call (re-checked after repairGhostRefs, so a runway the repair dropped is caught in the renderer) and refuses same-named runway ends, and the save-ground-painter-data handler counts "$k": "runway:…" keys in the patched text and throws with diagnostics (canvas runway count/names, written count, writer warnings) — the game throws ArgumentException: runway.initialRunways is empty on a level with no runway. _remapRunwayNameFields also rewrites runway-change frame Source/Dest (those fields carry no Runway substring), so a renamed runway's timeline follows. Regression: tests/integration/runway_timeline_prune.test.js (+ auto-add assertion in tests/integration/scenery_physical_runway_cleanup.test.js).

  • NEW-Area write safety (do not regress): scenery_write.js:_sampleAreaShapes must sample the inner List<Vector3> type with a regex that tolerates the $id wrapper before $type (the List object is { $id, $type, $rlength,... }); otherwise the sampled list type is null and _fmtType(null) emits $type: 0, which Unity resolves to ArchiveHeader and rejects as InvalidOperationException: Invalid Area static entity (+ Entry of type "StartOfArray" in node "" is missing a name). The regex + null-safe fallback type strings are in scenery_write.js; deep detail + the _synthesizeArea shape are in references/acl-format.md "Areas — Writing a NEW Area". Fallbacks resolve against the file's own blobdoc table first (_resolveTypeOrFresh, shared with _sampleStandShapes) and allocate a fresh id above the scope max otherwise — never hardcode 31/32/33 (ZSJN: 33|List<Vector3> collided with 33|Dictionary<string, IStaticItem> → Type id 33 claimed by both ... when re-adding an area after deleting them all; regression tests/integration/area_readd_after_delete_all.test.js). Bare-$type read-back: the fresh id is registered inline on the FIRST area entry only, so sibling areas carry a bare "$type": <id> — both area readers (scenery_graph.js:_isAreaEntity, scenery.js:_parseAreas) must resolve bare numeric ids through the blobdoc type table (getBlobTypeMap / a local scan) or freshly saved areas are invisible to the editor while the game still renders them.

  • Ground Painter — per-scope $type safety + runway save gates (hardening): every synthesized-entity $type fallback resolves against the owning $blobdoc scope's type table first, else mints a fresh id above that scope's max carrying the canonical type NAME (_resolveTypeOrFresh for areas/stands/runway sub-objects, _resolveRuntimeScopeType for checkpoint-frame runtime entities; one fresh id per name per patch via fresh.byName; _scopeMaxTypeId tolerates whitespace before }). Why: type ids restart per $blobdoc, so hardcoded ids collided — 33|List<Vector3> vs 33|Dictionary<string, IStaticItem> when re-adding an Area after deleting them all, 20|Stand vs 20|Runway+Route, and the runtime 3|PhysicalRunway vs the RuntimeEntities scope's 3|Jetway (Type id N claimed by both ...). The previous _assertSampledType "no fallback, throw" policy is superseded (a fresh id cannot collide, and Odin resolves types by name), which also unblocks adding a runway entrance/exit to a level whose Entries/Exits were emptied (the element type is then neither sampleable nor in the table). Companion gates: (a) _gateSurvivorDanglingRefs prunes per-element a survivor runway's Entries/Exits whose serving-taxway nodes were deleted (via _pruneRunwayEntryExitElements, $rlength recomputed, ground_painter_writer_gate_runway_entries_pruned warning) instead of dropping the whole runway → no more "at least one runway" refusal after deleting all taxiways; structural refs (ThresholdPoints/EdgePoints/TouchDownPoint/LabelPositionNode) still drop the runway when unrecoverable, and a surviving runway's referenced node is still rescued; (b) a NEW runway whose thresholds no longer resolve is dropped with a structured ground_painter_writer_new_runway_dropped warning rather than vanishing; (c) renderer GroundPainter.save() re-checks the runway count after repairGhostRefs and refuses same-named runway ends (ground_painter_validation_runway_same_name) before IPC; (d) save-ground-painter-data diagnostics append the canvas runway count/names, written count and writer warnings to the "at least one runway" refusal. Regressions: tests/unit/scenery-type-resolution.test.js, tests/unit/scenery-area-bare-type.test.js, tests/integration/runway_gate_synthetic.test.js, tests/integration/runway_entries_prune_on_taxiway_delete.test.js, tests/integration/area_readd_after_delete_all.test.js, tests/components/EditorScreen/GroundPainter/saveRunwayGuards.test.jsx. Audit note — remaining hardcoded fallbacks: _sampleShapes (taxiway node/segment 3–8), _sampleAirwayShapes (9–12), _sampleTaxiNavShapes (27/28 + 5), _sampleJetwayShapes (34/35) still fall back to fixed ids when the file declares none of that kind; they are only reachable when a level has zero entities of that kind AND its scope reuses those numbers for other types. Migrate them to _resolveTypeOrFresh if such a level shows up.

  • Ground Painter — runway entrance/exit access + taxiway visual-path continuity + PK type regroup: buildSceneryGraph now aggregates each physical runway's directional Entries/Exits tables (runway:01/runway:19 blocks) into the collapsed graph runway (rw.entries/rw.exits — each holds name, runwayName, holdingIdx, lineUpIdx/exitIdx, defineIdx, isLeft, plus raw $irefs) and snapshots them into meta.runwayEntriesOrig for the writer's dirty-check. The painter surfaces a runway entrance/exit checkbox panel for a selected taxiway (src/components/EditorScreen/GroundPainter/runwayAccess.js, pure+tested): a taxiway is eligible only when it shares a graph node with a runway's coupled pavement-strip chain (RUNWAY_PAVEMENT_FLAGS=4; live graph name-match with meta.runwayPavement fallback, so a junction node created by splitting a pavement strip counts even when the meta snapshot is stale). A name match against an existing entry is NOT a physical connection; eligibility ignores the taxiway name, but toggling requires one (toggleRunwayAccess writes the entry/exit node indices + patches node Type/Flags; on runway rename it keeps entries/exits direction-grouped). Save (scenery_write.js) re-serializes the two directional wrappers (_buildEntriesWrapperForPatch/_buildExitsWrapperForPatch) and samples the element type ids (Runway+Entry/Runway+Exit) distinct from the array wrapper types (...[]) — sampling first, then the file's $blobdoc table, then a fresh id above the scope max (_resolveTypeOrFresh), never a colliding hardcoded id (a guessed id produced "Type id N claimed by both ..."). Taxiway auto-slice split (GroundPainter.jsx/fillet.js osmFromSegPk) now carries parentOsm onto split pieces so a runway's type-4 pavement stays ONE visual path (re-emitted as later ordinals of the SAME OsmId instead of fresh negatives — fixes Taxiway visual path 'N' is discontinuous) and _synthesizeSegment/_synthesizeNew copy the parent survivor's visual properties (Name/Flags/IsHidden/IsUnselectable) onto a split piece — Unity requires every segment of one OSM way to share them, else InvalidOperationException: Taxiway segments '...:0' and '...:5' for OSM way '...' have inconsistent visual properties (KDCA_leisure_2 -379142, ground fuzz; a hardcoded IsHidden/IsUnselectable = false on the synthesized piece was the cause; Name is part of the signature too — KDCA_leisure_2 -378884, air-map fuzz, renaming ONE piece of a 22-segment way left :2 named "T42" while the other 21 were unnamed). To hold the invariant the writer propagates an edited survivor Name across its whole OSM way (segNamePatch expansion), canonicalizes each OsmId group in _renumberTaxiwaySegmentOrdinals (_canonicalSegVisual/_patchSegVisual), defeats the lossless no-op when a group is already inconsistent (_hasInconsistentSegVisual), and the MCP/UI segment rename spans the target's OsmId); _splitGroupIntoLinearChains decomposes each OsmId group into maximal linear chains (orienting each via _reverseSegmentNodes), keeping the longest chain on the original OsmId and moving branch offshoots (a fillet stub attached at an interior degree-3 node) to fresh negative OsmIds, so a split piece lands at its true ordinal and the path stays continuous (covered by tests/integration/taxiway_path_continuity.test.js); a genuinely-new taxiway still gets a fresh OsmId. patchSceneryBlob now regroups PKStaticEntities into the file's canonical type order (_pkTypeOrder from first-appearance + _regroupPkByType, stable within a group) so a newly-drawn taxiway-node joins its type block instead of the array tail. Detail in references/acl-format.md; tests: tests/components/EditorScreen/GroundPainter/runwayAccess.test.js, tests/integration/runway_entry_type_id.test.js, tests/integration/scenery_taxiway_split.test.js, tests/integration/scenery_type_regroup.test.js, tests/integration/taxiway_visual_props.test.js.

  • Ground Painter — air mode (unified Ground/Air painter): an air/ground toggle (GroundPainterToolbar.jsx mode button → onToggleMode; mode==='air' sets isAirMode in GroundPainter.jsx, setGroundPainterMode on the store) switches the same SVG world from ground scenery to the airside view for editing airway nodes (FIX waypoints: graph.airwayNodes[], each {x,z,name}) and procedures (graph.procedures[], each {name, routeType, runwayName, airwayNodeIdxs}; routeType 0 STAR / 1 APPR / 2 SID / 3 MISSED, colored per type on the map). Tools: Air Node (n — free place/rename a fix), Air Procedure (c — click existing fixes to chain a route; commitProcedure validates name+runway, checks duplicates, pushes paired history), Air Fillet (f — strictly intra-procedure AAA: findNearestAirwayEdge edge-based, two consecutive edges sharing the interior FIX within the SAME procedure; after first pick highlight/snap locked to that procedure's edges; computeFillet on two 2-point edge segments, airFilletPreview + #ffd34d/#8a94ff highlights, radius 50..500 GU step 5 via FILLET_LIMITS (ground 0.5..5.0 step 0.05; useEffect resets to 120/2.0 on mode switch); commitAirFillet intra-path replaces O with T1+arcInterior+T2 nameless nodes and reroutes airwayNodeIdxs; inter-procedure edge picks fall back to legacy truncation + ARC#### bridge; new i18n ground_painter_air_fillet_error_same_procedure), plus the shared Select (a)/Box Select (s). **Both airwayNode and procedure are selectable via single-click / shift-click / hover: pickForeground air path checks findNearestAirwayNode then findNearestProcedure (hover + click share priority, filtered by activeRunways/activeProcTypes plus bgImage fallback), HoverOutline highlights airwayNode double-circle or procedure dashed polyline (#ffd34d 8 5), SelOutline is thicker 0.34*strokeScale when isSel (selectedProcedureIdx===origIdx or isMultiSelected('procedure',origIdx)). Drag: selected node → mode:'node' on the fix; selected procedure → body drag within airBody=max(0.30, airGrab*1.15) via origAirwayNodes translating all its fixes, or node-grab on any member fix; pan if selection exists but cursor not on it. Delete: hasAirSel checks selectedAirwayNode OR selectedProcedureIdx; single selectedProcedureIdx splices procedures+airwaySegOrigPk and records deletedAirwayPks; multi airProcIdxs (click/shift only — box marquee stays node-only) are deleted directly before the degenerate-<=1 cascade and node reindex. Fillet commitAirFillet head/tail invariant: T1/T2 mapped to procedure direction (prevNodeIdx → tPrevIdx/tNextIdx, orderedInterior reversed when needed) so B→A equals A→B. renameAirwayNode tick now closes box (setSelectedAirwayNode(null), matching renameProcedure). isTextEditTarget robust (tag.toUpperCase(), !!isContentEditable, comment notes forward-delete) and runway/stand/segment/airway floating inputs add onKeyDown e.stopPropagation(). Air translation-only — rotateSelection/multiRotate/[ ] shortcuts early-return when isAirModeRef.current, buildRotationPlan still collects airwayNodes for ground but multiRotatePlan is null in air, group drag carries origAirwayNodes and uses bounding-box hit for multi. Airway node markers are zoom-aware constant-on-screen-size (DOT_R = 5/pxScale, HL_SW = 1.5/pxScale; base DOT_R*1.0, selected DOT_R*1.5 — the airway-node render block). The node name is NEVER rendered on the map — editing is via a floating rename box anchored to the selected point (see the floating-rename bullet). The window keydown handler early-returns on text-edit targets (isTextEditTarget), so Backspace edits the name instead of deleting the node. MCP create_airway_fillet mirrors the intra rule (procedure+vertex 1..len-2 or legacy procA==procB auto-pick sharpest corner, radius 50..500; procA!=procB rejected). i18n ground_painter_node_name (zh 导航点名称 / en Airway node name).

  • Ground Painter — air-mode floating rename (FIX + procedure): when a FIX is selected (isAirMode && !committingProcedure && selectedAirwayNode != null) or a procedure is selected (selectedProcedureIdx != null), GroundPainter.jsx renders a floating box anchored to the selected point (airwayNodeRenameOverlay / procedureRenameOverlay) positioned in screen space via svg.createSVGPoint().matrixTransform(ctm) with viewBox fallback, clamped to the SVG rect (210×36, 18px offset, 8px margin). The box (#0a1628 + #ffd34d border, boxShadow) contains a text input bound to renameAirwayNodeName/renameProcedureName (pre-filled via useEffect on selection), a Cancel [×] (setSelected* null) and Confirm [√] (rename*()→ clone graph + set name + paired history). Enter commits; onMouseDown/onClick stopPropagation() so the map doesn't deselect. The old bottom .gp-proc-bar gp-proc-rename bars are removed. The same isTextEditTarget guard (tag.toUpperCase(), !!isContentEditable) keeps Delete (forward) / Backspace / Escape / Ctrl+Z inside the input; tick-rename now closes box.

  • Ground Painter — air-mode background grid (10 NM): in isAirMode the SVG renders a 10 NM square grid (NM_TO_GU = 18.52, step = 10*NM_TO_GU = 185.2 GU) centered at Unity origin (0,0) (GroundPainter.jsx: air-grid block). Vertical/horizontal lines from floor/ceil(viewBox/step)*step at 0.9/pxScale (#1e3a5a, 0.38), axes 0 at 1.6/pxScale (#2f5a8a), origin marker (4.2/1.6 px) + 0,0 label (#6aa0ff), and per-10-NM axis labels (maxLabels 20, #3a5a8a). Grid is pointerEvents:none and drawn behind all other layers (just after <image> bg, before ground/air branches).

  • Ground Painter — hover preview (single-select + box-select, air + ground): GroundPainter.jsx: HoverOutline renders a dashed #ffd34d preview of what a click would select when (tool===TOOL_SELECT && selectEnabled) || tool===TOOL_BOX_SELECT (hovered state {kind,idx} set in onMove; isHoverTool guard, useEffect clears when leaving select modes or toggling isAirMode). Priority mirrors the Select tool: stand tightStand vs segment vs area (boundary cost 100 interior-aware) vs bgImage; air: airwayNode then procedure (findNearestAirwayNode else findNearestProcedure with active filters, else bgImage). Hover is hidden while dragging (onMouseDown clears hovered), cleared on onMouseLeave, and suppressed when the hovered object is already selected (selected/multiSelected/selectedAirwayNode/selectedProcedureIdx). Box-select also shows hover so a click (tiny box) behaves like single select. Render: segment polyline + dots, runway polygon, area polygon, stand rect+line, airway node double circle (1.45/0.95×), procedure polyline (hSw*1.05 8 5), bgImage rect. Never interferes with SelOutline (solid highlight).

  • Ground Painter — select snap in air mode + procedure init snap: onMove in isAirMode && tool===TOOL_SELECT && selectEnabled calls findAirSnap(raw) and sets world to the snapped endpoint (SNAP_TYPES.ENDPOINT) for a visible SnapIndicator (isAirMode && world.type===ENDPOINT). TOOL_AIR_PROCEDURE now snaps on the init node (first pick) as well (if(tool===TOOL_AIR_PROCEDURE) without the committingProcedure.length>0 gate). handleMouseDown hides hovered on drag; onMouseUp cleared.

  • Ground Painter — right-click / areaType / loading / mode-reset: onMouseDown button===2 (a) backtracks the air procedure draft (committingProcedure length>1 ? slice(0,-1) : null, clear gpError) when isAirMode && tool===TOOL_AIR_PROCEDURE, and (b) clears air-mode selection (selectedAirwayNode/selectedProcedureIdx) when not backtracking — ground selection (selected/multiSelected/boxRect) is still cleared in all modes. Area hit-test (pickForeground + MCP _resolveGroundTarget) for areaType===0 (boundary) is inside-aware: if(edge>TH && !inside) return; cost = inside ? 100 : 100+edge — so an interior click selects the boundary even when the cursor is far from the edge. GroundPainter.jsx shows a loading overlay (isInitialLoading / modeTransition → gp-loading-overlay + gp-spinner + gp-loading-text, aria-busy, .gp-empty kept hidden for legacy tests) with i18n ground_painter_loading / ground_painter_loading_air / ground_painter_loading_ground (src/utils/i18n.js zh 正在加载…/正在加载空中视图…/正在加载地面视图…, en Loading…/Loading air view…/Loading ground view…; GroundPainter.css .gp-loading-overlay, +--initial, .gp-spinner, @keyframes gp-spin). src/store/appStore.js: toggleGroundPainter always starts in ground mode (groundPainterMode:'ground', groundPainterTool:'select', groundPainterActiveRunways:null when opening) — reset persisted painter state on every enter. GroundPainterToolbar.jsx air/ground toggle keeps position:relative for the spinner anchor.

Reference Files

This skill uses progressive disclosure — the central SKILL.md (this file) stays lean (under ~210 lines) with the rules that apply to every task. Detailed information for specific domains lives in reference files. Read the relevant reference file(s) when you need deeper detail.

Domain Reference File When to Read
Architecture & Conventions references/architecture.md Adding files, creating IPC handlers, writing tests, understanding project structure, coding conventions
Data Flow & Cache references/data-flow.md Working on save/load, cache changes, stand/star maps, demo files, stand conflict detection
Map Windows references/map-windows.md Working on ground/air radar, flight strips, map hooks, ControlSidebar, SpinKnob, witch mode, map i18n
UDP Telemetry references/udp-telemetry.md Working on UDP listener, binary protocol, telemetry pipeline, command channel
ACL Format & Approach Math references/acl-format.md Working on ACL parsing, approach aircraft, scenery data, State=30/State=5 math, TAT formula, Ground Painter id-free scenery graph/read-write
MCP / AI Agent Integration references/mcp-integration.md Working on API server, MCP tools, SSE endpoint, store-update IPC bridge, AI agent control flow
Livery Editor references/livery.md Working on custom aircraft liveries, the Livery page/painter, livery IPC, or the share/export contract
Cloud LLM / Chat Panel references/mcp-integration.md Working on electron/cloud-llm.js, ChatPanel component, multi-vendor chat, tool-calling loop, thinking/nudge
Dev Commands references/dev-commands.md Running, testing, building, or releasing the app

Quick Summaries

  • architecture.md — Full directory tree, backend (CJS) and frontend (ESM/React) conventions, IPC patterns, three-layer test strategy, three-screen SPA flow.
  • data-flow.md — Phase 0 cache init → Phase 1 load → Phase 2 edit → Phase 3 save pipeline. Cache version detection, stand conflict validation, stand/star map overlays, demo.acl file handling.
  • map-windows.md — Separate BrowserWindow architecture for GroundMapWindow, AirMapWindow, and FlightStripsWindow. Shared hooks (useSvgZoom, useUdpAircraftState — now surfaces udpConnected, useCrossWindowSelection, useWitchAnimation, useKnobPositions), ControlSidebar with SpinKnobs, witch mode, cross-window selection sync, drag reorder, and the shared LiveSessionOverlay (blurred "no live session" notice driven by udpConnected === false). AirMapWindow draws only the witch-mode background.
  • udp-telemetry.md — Binary protocol (40B header + 112B records), trail ring buffer, outbound command channel, 200ms live state push, auto-reset mechanisms (stale timeout, hasLevel transition, airport transition).
  • acl-format.md — Unity JSON extensions, two-pass preprocessing, section types (v4 GATCARC4 binary only), $blobdoc nested documents, PKStaticEntities $iref/$id. Complete State=30/State=5 approach aircraft construction math: unified path, PR formula, 3° glideslope Y, TAT computation from SceneryData, approach ceiling, module API reference.
  • mcp-integration.md — API server (port 31415), 8 MCP tools, 14-point validation (time bounds allow end +30 min grace + runway inactive at landing via chronological active-set sweep), SSE/JSON-RPC endpoints. Also covers electron/cloud-llm.js (DeepSeek/Gemini/Claude/Codex chat with tool calling) and the ChatPanel React component.
  • dev-commands.md — All npm/node commands: component tests, E2E tests, integration tests (with --acl and --root variants), local build (node build.js), GitHub release workflow.
  • livery.md — Custom livery page: free-form folder = opaque storage key + sanitized manifest id, 20-row short-code table (derived from targetPlaneId) plus a list-aircraft-types-scanned aircraft-type dropdown, component/IPC map (painter page with left rail + an N-panel canvas (one 2048² panel per built-in BaseMap — multi-image A388/B38M are two, tied by a 128px PANEL_GAP gutter; no tab strip — a left click in a panel makes it active (keyboard shortcuts never change it), the padded overlay is the pointer surface so scale/rotate knobs work outside the 2048 square, and multi-image movables render (and export) each in their own panel — clipped to the panel their centre is in, never only the active one (movement unbounded, overflow clipped); read-livery-images/readLiveryImages loads and exportParts/create-livery({images}) saves every panel, baseFileName maps part→file) / opaque base primed from the per-aircraft built-in UV template via get-aircraft-template (electron/dds.js DXT1/DXT5/DXT3→PNG, Y-flipped — the shipped DDS BaseMaps are stored bottom-up, so a raw decode painted the atlas upside down vs the in-game orientation the livery packs use; Clear always restores that type default, and a new canvas is re-primed on type change only while untouched) / multi-object live layer (sticker/text/shape objects stack and all stay selectable — a new shape never flattens the previous ones; undo snapshots carry {img, objects, selId} and each move/resize/rotate/vertex drag pushes one pre-drag snapshot (scaling is undoable with Ctrl+Z); corner drags free-stretch by axis while Shift keeps the aspect (a free-stretched text box stores a per-axis stretch instead of a font size); selecting a text box re-exposes font/size/bold/italic and double-click re-opens its inline editor (H/V flip the selected object, Enter clears the selection instead, Ctrl+C duplicates, I imports a sticker; Ctrl+S/Ctrl+Shift+S save/save-as, which stay in the painter and keep the live object stack — a save never re-reads the flattened PNGs it just wrote, so movables are not baked into the base and the eraser still removes them); right-click pins a layer-order menu (to-front/forward/backward/to-back via pure reorderObjects, end moves disabled) only in Select object mode — every other tool's right-click picks the colour instead (eyedropper composites live objects, so a sticker/shape/text colour is pickable) and rail/letter shortcuts + undo/redo settle mid-gesture strokes/previews/drags/lassos first (settleGesture/activateTool); Select offers object/pen/wand sub-modes (tooltips advertise A/L/W; those keys work from ANY tool and switch to
    Select first, so Line moved to U and circle/ellipse to M) with a
    selection mask (combine/erase/replace default combine, wand tolerance,
    Deselect, Ctrl+D deselects)
    clipping brush/eraser/fill destructively (a movable added while a selection exists is stamped with that mask
    (clipMask) at creation and stays clipped to that shape forever — Ctrl+D / a new selection never un- or re-clips
    it; one placed BEFORE any selection stays whole; paintObjectMasked; movable chrome — blue box + rotate/scale handles —
    draws only in Object mode, so pen/wand show just their outline on top), setSelMode/tool-switch
    leaving Object mode cancels the movable selection while a pen/wand mask survives, the whole selection outline
    (mask border + lasso draft) is drawn last, and the pen (brush/eraser) is always on top with the fill always at the bottom — the canvas is
    five stacked data-layer layers base → fill → objects → paint → chrome, so raster paint (pen) renders above the live movables
    while the flood fill sits under them, both without baking them (export/pick/wand composite the same order), outlined by tiny dense round
    white dots with a hairline black border
    (zero-length round-cap dash [0, 3/z] stroked twice, black 2/z under
    white 1/z) chained by pure
    traceMaskBorder/chainBorderSegments; on a multi-panel type a lasso/wand selection is confined to the ACTIVE panel
    (clipActivePanel + wandRegion region bounds) so it can never cross the gutter, and an activeRef mirror makes a
    single press into another panel run the wand/fill/lasso there (the active state update is async); each movable carries a
    persisted panel chosen by the POINTER while it is dragged (paintObjectInPanel/objectPanel clip render, export,
    wand and eyedropper), so a sticker grabbed by its edge follows the cursor across the gutter before its centre crosses and
    exports into the panel it was dropped in; the wand samples ONLY the visible
    colours (base → fill → movables in their visible clipMask-clipped form → paint, in a dedicated buffer) so a sticker/shape
    colour is selectable but invisible geometry never is; the fill tool reuses that same composite sample to pick its region
    (then writes only the matched spans into the bottom fill layer — flooding the transparent fill layer itself ignored tolerance
    and filled the whole panel), and an imported sticker hands over to single-select (Object mode) so it is immediately moveable; and Del with a selection is the marquee eraser (both raster layers punched transparent with the base showing through +
    erasePolys holes punched into every touched movable; no selection still removes the selected object); the Deselect tooltip advertises Ctrl+D and the rail Select icon is a mouse / magic-wand pair; custom RGBA colour picker (LiveryColorPicker.jsx — HSV square + hue/alpha rails + hex, per-stroke alpha layer); Line tool Straight/Curve sub-modes) / Save+Save As naming dialog + painter Delete-this-folder confirm, airline/aircraft pickers wrapped in <span> not <label> so a pick just closes the list, list with an in-card select checkbox + header-bar Export/Delete acting on the selection (the list view state — scroll offset, collapsed groups, search filter — is session-persisted in appStore as liveryScrollTop/liveryCollapsedGroups/liverySearch, cleared by setRootPath, so leaving the page and returning keeps the user's place), export-livery-to-dir, page-scoped help overlay documenting list vs painter buttons incl. deleteThis + painter preview3d chip, with the mod-enable warning as a TOP highlighted tip (#livery-help-tip, margin-bottom:18px) on both pages), pack-root mod_info.json auto-write/repair on load+create, Save As overwrite confirm + post-save mod-enable hint (cache.json flags, logic in pure electron/cache-flags.js), renderer-canvas image rules, share/export ZIP contract; 3D preview (no Python required) — LiveryScreen extracts the pack once on page entry in the background (livery-3d-get → livery-3d-ensure → livery-3d-bin/livery-3d-cleanup, electron/aircraftModels.js + pure-JS electron/unity/ reader of the user's own resources.assets, scripts/extract-aircraft-models.py UnityPy is last-resort fallback only, three dependency, pack version 2 cached under <userData>/livery-3d-models/ and deleted on page leave; painter LuRotate3D button right of the aircraft select hidden for types with no model via has3DModel (Cessna Citation X), floating Livery3DPreview.jsx window with live ~1 s getRevision/getPanelCanvases() full-res canvas-texture updates (no PNG round-trip); list cards render 3D-only snapshots via src/utils/livery3d.js — the resized PNG thumbnail channel is NOT used); multi-part types (A388/B38M) — a livery's parts[].partName must match the built-in default's main part (Fuselage, not Body) or the game ignores the texture; image read/preview resolves the manifest's main-part BaseMap (base_Fuselage.png, any-part then legacy base.png fallback, PNG/JPEG) and export-livery ships every image; each aircraft folder in the list also renders an add-livery card
    (onCreate(planeId) → painter with that type pre-selected, onEdit({targetPlaneId}) fallback), including scanned
    types with zero liveries; the list also surfaces Steam Workshop liveries (best-effort workshopContentDir walk-up + listWorkshopLiveries, read-only Steam badge cards, pack:'workshop' reads); Workshop publish — the list header Upload button (single mine selection) and the painter Steam button open UploadLiveryDialog (get-workshop-publish-info / publish-livery / open-workshop-log / workshop-debug-info, workshop-upload-progress) backed by electron/steam-workshop.js (constant appid 3328490, the shipping game, .workshop.json sidecar identity, verbatim content pack, preview save/reuse + 1 MiB shrink, create-or-update — a .workshop.json sidecar recorded under a previous host app is ignored and republished fresh under the current host; all SteamAPI calls run in a short-lived plain-node child — steam-workshop-bridge.js spawns steam-workshop-worker.js (pure logic in steam-workshop-core.js, shipped via build.js extraResources) and tears it down after each prefill/publish + on an idle timer + will-quit, so Steam does not keep reporting the game as running; unit tests inject a fake lib for the in-process path; see references/livery.md "Workshop publish (upload)") and the painter top bar has an Open folder button (reveal-livery-folder → shell.openPath, falls back to the own pack dir for an unsaved livery).

Schema

All .acl files use the v4 schema — a GATCARC4 binary container. v4 is the only format: there is no schema detection and no isV4 flag anywhere in the code. The container has two storage versions (v1/v2 — see the binary-container bullet below); the payload schema is identical across them. A v5 content variant (e.g. ZGSZ: StreamingAssets/aircraft_profiles.csv + per-level SceneryData filtering) keeps the container format but changes content: specs move to the global CSV (shared across all airports), PhysicalName moves inside PhysicalRunwayStaticItem (inline or $iref to inline), Area type shifts 30→31, and each level carries only a subset of STAR/SID/APPR/runways — the cache merges across every .acl in the airport (see buildApproachCache v5 merge). Every parse/extract/build path still goes through buildPkIndex() → PKStaticEntities + $blobdoc navigation.

  • Binary container: Files are stored on disk as GATCARC4 binary (magic "GATCARC4", SHA-256 payload integrity, append checkpoint frames). There are two storage versions — v1 (Odin-binary header + NODH) and v2 (UTF-8 JSON header + NODS, a length-prefixed main LevelPayload Odin segment + NODH, frames with an embedded NODF commit and no outer hash). readAclText() decodes either version to Odin JSON text; writeAcl() re-encodes preserving the on-disk version (v2 stays v2, v1 stays v1, new files are v2) — but every document is first renumbered by src/acl/id_renumber.js (renumberAclIds): $id declarations and $iref references are rewritten to a strictly ascending sequence in text order using one shared counter per document, with $blobdoc values renumbered as independent documents (fresh 1..N) sharing a segment-wide old→new registry (so outer-document aircraft: stub entries can $iref ids declared inside a byte[] payload). The game's checkpoint reader (JsonDataReader) requires ascending ids in text order; the rebuild's construction-order allocation (wrapper $id 1123 before its inline Aircraft 1120) plus kept low ids after new high ones made the game misbind DockingAircraft inline values → SetDockingTarget(null) NullReferenceException per docked jetway at level init. Dangling/forward $iref handling: a $iref whose target is not yet declared is preserved verbatim and its value reserved (never re-allocated to a fresh $id) — the game reads a dangling $iref as null. The tradeoff is a same-scope forward $iref in a file with id gaps can silently misbind. Related: when writeAcl receives options.originalText, renumberAclIds repairs $type re-registration — ids recoverable from the original text's declarations are re-introduced as inline N|Name forms at the first surviving bare ref (armed only on the Ground Painter path: scenery_write.js/electron/main.js pass originalText; the flight-save path does not). $eref/$fstrref/$guidref are untouched (different id spaces). countIdDescents(text) is the diagnostic (violations/dups/max per scope, blobdoc-aware — it DOES descend into nested $blobdoc values; healthy game files score 0). Covered by tests/integration/id_renumber.test.js. v2 frames: writeAcl copies the JSON header verbatim from the original buffer but re-encodes each decodable checkpoint frame from its edited decoded text (buildV2FrameSegment), so frame edits/reconciliation (orphan runtime-entity removal, stale $fstrref nulling, $iref remapping) persist for v2 as well; frames that do not decode to a checkpoint doc are copied verbatim. Before this fix framesRaw was re-copied verbatim and the edits were silently dropped — the game then aborted GameStateRegistry.RestoreWorld on stale runtime entities (the fuzz-produced broken leisure levels). Covered by tests/integration/gatcarc_v2_frame_edit.test.js and tests/integration/v2_frame_reconciliation.test.js.
  • Flight plans: Flight-plan entries live in StaticData.$blobdoc.StaticItems.$rcontent with flight-plan: key prefix. Uses InitialArrival/InitialDeparture field names. InBlockTime/TakeoffTime always 0 (game computes dynamically). AirlineName stores the 3-letter airline code (e.g. "CDG"), defaulted to the callsign prefix for new flights. The leg is chosen by the internal isDeparture flag (set at creation; the serializer falls back to OffBlockTime presence — _isDepartureFlight()).
  • Type numbering: $blobdoc sections have independent type numbering from the outer document. Type N inside a $blobdoc can mean a completely different type than type N outside it. Both save and load paths maintain separate type maps (per segment in the save pipeline).
  • Frontend integration: Hides InBlockTime/TakeoffTime columns. Skips time-order validation (InBlockTime/TakeoffTime always 0/unset). New flights skip InBlockTime/TakeoffTime defaults.
  • No pre-spawned aircraft: Files have no WorldState section and no pre-spawned State=30 or State=5 aircraft. Approach/State-5 extraction functions (extractApproachData, extractState5Data) return empty.
  • Designator mapping — two-pass with jetway fallback + global CSV (v5): buildDesignatorMapping cross-references StaticItems (flight-plan → Registration, AircraftType, Stand) with RuntimeEntities in two passes to build the AircraftType → Designator map. Pass A scans aircraft:REG entries for Specification.Designator (linked via Registration). Pass B scans jetway:STAND entries for DockingAircraft.Specification.Designator (linked via Stand → AircraftType from the static-item Stand field). This handles aircraft whose only runtime representation is inside a jetway's DockingAircraft rather than as a standalone aircraft:REG entry. Pass B resolves each jetway key through jetwayKeyStandCandidates (see below) so a sub-jetway ("it4-A7-1") whose spec is hosted only in its parent's stand form still maps to the flight-plan type. v5: when StreamingAssets/aircraft_profiles.csv exists, loadGlobalSpecDB(airportDir) seeds both specDB and designatorMap globally before the two-pass scan — the CSV is the single source of truth (parseAircraftProfilesCsv parses AircraftType,Designator,AerodromeCode,WakeCategory,WheelBase,ModelOffset,WingSpan,DockingPositions,VR,ToL; keys are indexed by both AircraftType and Designator). The scan then augments (never overwrites) the global maps and the save path falls back to generic defaults when a type is still missing (see fallback bullets).
  • Runway pairs from static PK entities: extractV4RunwayPairs(aclText) (in src/acl/utils.js, re-exported via the parser facade) derives the runway-change pair options for the RunwayEditor from the static runway objects in PKStaticEntities — each physical runway ("4L/22R") appears as two "$k": "runway:*" entries sharing a PhysicalName, yielding the reciprocal pair 4L|22R + 22R|4L. electron/main.js calls extractV4RunwayPairs on the first ACL in _collectAirportRunwayPairs (collectRunwayPairs remains only as a fallback if extraction throws). Note the flag-clearing rule in useEditorSaveActions.jsx: timelineModified.runway must be cleared on save regardless of runwayTimelinePath (the ACL patch in save-acl always runs; most real levels have an empty Config.runwayTimelineFile → no sidecar JSON, so the flag must still be cleared or back-navigation falsely reports unsaved changes).
  • Stand alias registration in designator mapping: the Stand → AircraftType map used by Pass B registers alias forms of each flight-plan stand so the exact jetway-key lookup resolves — flight plans say "31" while jetway keys are "31A"/"31B", or "07" vs "7". For stands with a numeric prefix, aliases are: the raw string, the un-padded number, the zero-padded (2-digit) number, and each with A/B suffixes; first-registered wins per alias (flight-plan scan order). Non-numeric stands (e.g. "it4-38") register only their exact string — no suffix aliases. Note: a stand's aliases claim its sibling sub-position names too ("31" claims "31A"+"31B"; "1A" claims "1B"), so when sibling jetways host different aircraft types the first-scanned type wins for the whole family.
  • Jetway-key → stand candidates (jetwayKeyStandCandidates, src/acl/approach.js, exported via the parser facade): Jetway keys carry spatial suffixes the flight plans omit — sub-jetways "it4-A7-1"/"it4-A7-2" belong to parent stand "it4-A7", and numeric stands are referenced as "31A"/"31B" by jetways but "31"/"07" by flight plans. The helper returns candidate stand keys in lookup precedence: exact key first, then each trailing -<digits> sub-position suffix stripped one at a time ("t1-3-1" → "t1-3" → "t1"); with includeNumericParse=true the parseInt-normalized form leads the list ("31A" → "31"; the alias forms still follow for the raw key). Consumers: buildDesignatorMapping Pass B (plain form — numeric alias coverage already comes from Pass 1's alias registration) and _rebuildJetwayEntries (numeric form first — the departure-stand lookup anchors on the first candidate when nothing matches, matching the game's own empty-jetway behavior).
  • Specification extraction — global CSV primary (v5): loadGlobalSpecDB(airportDir) (src/acl/approach.js:3777) is the primary spec source: it locates aircraft_profiles.csv via findAircraftProfilesCsv (walks up from airportDir → StreamingAssets/aircraft_profiles.csv or GroundATC_Data/StreamingAssets/...), parses it with parseAircraftProfilesCsv, and caches specDB+designatorMap globally (cloned per airport). extractSpecificationDB scanning of Specification objects inside jetway DockingAircraft / RuntimeData aircraft entries remains as fallback for pre-v5 fixtures and tests without a CSV. _extractFallbackSpec(text) single-spec extraction is intake-side only; emission uses the generic fallback (see below) instead of asserting.
  • v5 per-level SceneryData filtering — merged cache: Each level ships with only a subset of runways/procedures (e.g. ZSJN leisure_1 only RWY19, other levels only RWY01). buildApproachCache(airportDir) collects allAclTexts[] (every .acl text) and merges across all files: extractStarRunwayMappings per file → mergedStarRunway/mergedRunwayStar (deduped by runway), state5ParamsMap/appPointMap/starPaths/starWaypoints/runwayThresholds/sidPaths/missedAppPaths/apprPaths/airwayNodes all merged (first hit or longest-path wins, runway deduped). allAclTexts feeds every merge; the helper _tryAllTexts(fn,…) supports direct lookups. Without the merge the cache would cover only the first file's runway and aircraft could not be placed on the other runway.
  • PhysicalRunwayStaticItem indirection (v5): PhysicalName ("01/19", "16L/34R") moved from the top-level runway block into a nested PhysicalRunwayStaticItem object which may be inline ({ $id, PhysicalName }) or a $iref to an inline object (sometimes double-indirected via a physical-runway PK alias). _parseRunwayThresholds, extractStarRunwayMappings, extractStarWaypoints, and sid_goaround.js:_extractRouteMappingsByType now resolve via _extractNestedObject(block,'PhysicalRunwayStaticItem') → _findPhysicalNameByIref(aclText,pkIndex,iref) (PK index + double-$iref walk + raw-text "$id": N scan fallback for inline ids like ZSJN 01 $iref:8541) → PhysicalName inside the physical-runway object, with a top-level PhysicalName fallback for pre-v5 files and a runwayName placeholder when still missing. _findPhysicalNameByIref is exported for reuse.
  • Area type 30→31 (v5): scenery.js:_parseAreas now accepts both 30 and 31 for ContextCross.Models.Area — v5 shifted the type number from 30 to 31. Detection checks for the name substring ContextCross.Models.Area first (version-agnostic), then falls back to numeric 30|31 for both bare and quoted "$type" forms.
  • Ground Painter — id-free scenery Graph: the painter never stores $id/$iref/OsmId as primary keys. buildSceneryGraph(text) (src/acl/scenery_graph.js) parses PKStaticEntities (taxiway-node/segment, runway, stand) + NonPKStaticEntities (Areas 30|31) into a Graph {nodes, segments, runways, areas, stands} where all refs are array indices into nodes (shared nodes deduped by coordKey = x.toFixed(6),z.toFixed(6), so moving one node moves every incident segment/runway/stand). A parallel meta side-table {nodeOrigPk, segOrigPk, runwayOrigPk, areaOrigId, standOrigPk, runwayPavement, runwayOrigInfo} carries each object's original $k/$id so survivors reuse their original id at write; new objects get a fresh id (max+1, new nodes/taxiways use fresh negative OsmId auto-generated via minOsm-1 — no finite pool, no UI counter/limit). Type=2 diff (final): new taxiway-node → Type=2 (PushbackLimitPosition, ENodeType) + Flags=0; new taxiway-segment → Flags=2 (Taxiway, EFlags, was 1=StandInternalTaxiway) via ln.flags ??2 (GroundPainter.jsx:1221,1503 + fillet 1613/1614/1624/1637), seg.flags ??2 (api-server.js:706), _synthesizeSegment default 2 (scenery_write.js:511). patchSceneryBlob is lossless: surviving entity blocks are kept verbatim (so runway Routes/HoldingAreas and stand TaxiwayNode[], which the Graph does not model, survive byte-for-byte); only genuinely-deleted entries are dropped, only genuinely-new ones are synthesized. Moved surviving objects are patched in place, not kept stale: moved survivor node x/z via _patchNodePosition (movedByPk), moved survivor area vertex/body coords via _patchAreaPoints (detected by diffing each survivor's graph points against its original NodePositions.$rcontent; rebuilds $rcontent preserving the ambient $type/wrapper $ids and updating $rlength) — so a building/apron you drag persists on save. All allocation is centralized at write; writeAcl→renumberAclIds does the final ascending $id/$iref renumber (never called inside patchSceneryBlob — double-renumber would corrupt). Frozen geometry dims (taxiway half-width, runway width/edge/touchdown, stand length/pushback/holding rects) live as constants in src/utils/constants/map-config.js — no other file may redefine them (scenery_graph.js pulls them via src/acl/constants.js; the painter UI imports map-config.js directly; scenery_write.js currently falls back to its own literals). The painter snaps only currentPath (a per-level subset — runways/areas from other levels stay in their own files and are merged by buildApproachCache), so a save does not bump CACHE_VERSION.
  • Ground Painter delete persistence + taxiway ordinal contiguity + jetway reference integrity: four related facts drive a correct delete. (1) deleteSelected (GroundPainter.jsx) now records the removal in meta — pushes the object's original key into meta.deletedPks (segment/runway/stand) or original id into meta.deletedAreaIds (area), and splices the parallel meta array so it stays index-aligned with the Graph; it commits graph+meta together with a depth-1 history for Ctrl+Z. patchSceneryBlob only drops entries listed in deletedPks/deletedAreaIds, so without this the delete silently reverted (graph spliced, meta untouched → entry kept on save). (2) After a partial taxiway delete the surviving taxiway-segment:<osm>:<ord> siblings keep a gap (e.g. 1481:1..21 after :0 removed), which Unity rejects with "non-contiguous ordinal N; expected M"; patchSceneryBlob renumbers each per-osm group contiguous from 0 in the rebuild path. (3) Deleting a taxiway segment GCs orphan taxiway-nodes but preserves shared end nodes — GroundPainter.jsx:deleteSelected (src/components/EditorScreen/GroundPainter/GroundPainter.jsx:1246) collects the deleted segment's nodeIdxs (segNodeIdxs), checks each node against remaining segments/runways/stands; only nodes with zero remaining incident references are spliced from graph.nodes/meta.nodeOrigPk (original PK pushed to meta.deletedPks if origPk != null; surviving indices > delIdx are decremented for segments.nodeIdxs/aIdx/bIdx, runways.thAIdx/thBIdx, stands.noseIdx/tailIdx/pushbackIdxs, and meta.runwayPavement). Curve interior nodes (exclusive) are GC'd, chain-shared endpoints and runway/stand-shared nodes are kept; the writer then drops exactly those deletedPks entries. (4) Deleting a stand also resolves every reference to it (_cascadeOrphanEntries drops the stand's jetway:* static item from StaticItems, and _reconcileJetwayFrames drops the matching jetway:* runtime entity from the checkpoint frame) — otherwise Unity throws Jetway: static item 'jetway:NN' does not exist in CurrentLevel.StaticField.StaticItems and self-heals by deleting the stand + all its aircraft. Covered by tests/integration/scenery_delete_cascade.test.js.
  • Ground Painter snap engine — angle-based snap: snap.js findSnap(rawPos, anchor, snapGeom, opts) runs 1.1 endpoint (nearest vertex) → 1.2 on-segment (projection) → angle snap. The angle snap is relative to the last drawn edge (opts.prev = the vertex before anchor): it rotates the cursor around the anchor onto the nearest snapped turn — collinear (straight continuation — 180° vertex), ±90° (perpendicular), ±45°/±135° (diagonal) — keeping the cursor's radius, gated by a zoom-modulated angleToleranceDeg (base 2.5° via dynamicAngleTolDeg, clamped [0.6°, 12°] — the painter passes painterAngleTol(viewBox, baseVB)), and reports the vertex angle between the two edges (straight = 180°). The previous vertex comes from the in-progress path for the polygon/area tool (committing[length-2]) and from the committed chain for the line tool (lastChainPrev(graph, anchor) scans the last segment incident to the anchor). When a snap fires the UI draws an arc between the two edges at the vertex (snapArcPoints + angleLabel memo) plus an angle badge. There are no guide rays — only endpoint/on-segment snapping to existing geometry. Pure math — the tests (tests/components/EditorScreen/GroundPainter/snap.test.js) and MCP call findSnap directly. Node-drag snap exclusion (applyDrag, d.mode==='node'): the snap geometry passed to findSnap is filtered to drop the dragged node's own current coordinate and, when the drag carries runwayIdx/rwPav, every node of that runway's coupled pavement strip (Flags=4) plus any segment touching them. Without this the dragged threshold snapped to itself / to its incident segment endpoints / to the collinear strip vertices - it stayed pinned until the cursor crossed a full snap radius and then jumped between strip nodes ("endpoint drag comes loose after some distance"). Unrelated geometry still snaps; covered end-to-end by tests/components/EditorScreen/GroundPainter/runwayEndpointDrag.test.jsx.
  • fmtFloat3 format change: Both buildApproachAircraftBlock and buildState5AircraftBlock now emit float3 values without named fields — v.x, v.y, v.z instead of "x": v.x, "y": v.y, "z": v.z — matching the actual Odin serialization format. Five empty string-array fields in the RunwayCoordinator now share a single $id via $iref references instead of each getting a unique $id.
  • Stand heading from TailPosition→NosePosition: Stands link to taxiway nodes via $iref references (TailPosition/NosePosition). The _parseStandPositions path resolves these $iref values to coordinates and computes the heading from the tail→nose vector (Math.atan2(-dz, dx)), producing { x, y, heading, tailX, tailZ, noseX, noseZ }.
  • Stand nose/tail-based aircraft positioning: Both _buildStandaloneAircraftEntry and _buildActiveJetwayEntry compute aircraft parking position and direction directly from stand nose/tail coordinates. The formula is direction = normalize(NosePosition - TailPosition) and position = NosePosition - WheelBase * direction, where WheelBase is read from the aircraft's spec (defaults to 0 if unavailable). In _rebuildStaticDataSections, stand positions are parsed once via _parseStandPositions(text) upfront and passed as the standPositions parameter to all jetway rebuild calls. Jetway position lookups try multiple key formats (numeric parse, raw string, zero-padded) for robustness.
  • 0-flight ACL (save/load/re-save): A level may legitimately have ZERO flights (a scenery-only level, or a schedule that was fully cleared). _rebuildStaticDataSections now runs the full pipeline with flights=[], which REBUILDS-from-empty every flight-plan:REG / aircraft:REG / aircraft-animator:aircraft:REG entry (i.e. removes them) while preserving jetways, radio channels, singletons, and scenery, and still calls writeAcl so the cleared file is actually persisted. loadFlights returns an empty schedule (a warning log) instead of throwing "No flight data found in ACL", and the UI does not block Save/Save As when st.flights.length === 0. Because the game strips now-unused types from the flight-less StaticData.$blobdoc scope, the blobdoc type resolves for DateTime / FlightPlanDepartureLeg / FlightPlanStaticItem are gated behind hasFlights (see the "No fallback type numbers" bullet). Covered by tests/integration/zeroflight_save.test.js (clear + preserve jetways / reload empty / re-save) and tests/integration/animator_lean_scope.test.js (lean-scope animator fallback).
  • Save pipeline cleanup: After rebuilding StaticData (step 7), _rebuildStaticDataSections(aclPath, flights, undefined, approachCache, aclcfgStartTime, _saveSec, standPool) runs a multi-stage cleanup across ALL segments (header blobdoc + checkpoint frames). The caller (generateFullAcl in parser.js) pre-validates with _validateStandConflicts() (consecutive ARR→DEP pairs must have matching registrations — different aircraft cannot occupy the same stand simultaneously) and passes standPool = the renderer's sceneryMaps.standIdToGuid keys when available (the complete stand set for game-compat relocation). Each GATCARC4 segment is an independent Odin binary document with its own type numbering and $id namespace, so the pipeline creates per-segment type maps and per-segment _IdMapper instances for centralized $iref remapping. The resolved saveSec (from aclcfgStartTime, falling back to resolveConfigTime — _saveSec is ignored) is threaded through to _rebuildFlightRuntimeEntities for aircraft position computation. Execution order (critical — 7b MUST run before 7a so jetway detection sees intact $fstrref values):
  1. Game-compat normalization (_normalizeFlightsForGameCompat(flights, fullText, log, standPool, approachCache, voiceCatalog)) — FIRST, before any section navigation: auto-repairs the four fuzz-discovered init-rejection classes by MUTATING flights in place (so the StaticItems rebuild, frame rebuild, and CSV export all see the repaired state; returns { renamed, moved, starred, voiced }): (1) duplicate registrations (same reg on ARR + DEP → two flight-plan: keys + dropped docked DEP aircraft entity) — keeps the frame-linked side's reg (docked side → frame flight-plan direction → departure → first) and renames the other side to reg-suffix (loop of unambiguous letters/digits, then X<0..999>); (2) docked-stand occupancy — a dep at a docked stand occupies it from scenario start until its TakeoffTime (falling back to the frame's stored _departureTakeoffTime); other-reg arrivals may land there only if the docked dep's takeoff is in-scenario AND the arrival lands after its off-block; (3) arrival separation — two arrivals on one stand within GAME_STAND_MIN_GAP_SEC (20 min, empirical: game accepts ≥16 min) are rejected (DEP→ARR handovers at any gap are fine — game files ship 2-min handovers; ARR→DEP cross-reg is already gated by _validateStandConflicts). Violating arrivals are MOVED to a safe stand from standPool (plus stands named by frame jetway keys + stands in use). Docked state is derived from the CURRENT flights (earliest DEP per stand — the same rule _rebuildJetwayEntries uses), NOT the frame text, whose docked entries are pre-save/stale. (4) arrival STAR repair — the game drops an arrival leg with an empty STAR at FlightPlan.Init() ("has neither an arrival nor a departure leg"); every STAR-less arrival gets Airway filled with the first STAR of its runway from approachCache.runwayStarMap (game-authored arrivals always carry one, e.g. "SIE.CAMRM5"), and an arrival on a runway with NO STAR data at all (departure-only runway) is MOVED to the first arrival-capable runway (sorted keys) so the saved plan never ships a runway/STAR combo the scenery doesn't support. The same rule is enforced as a save gate: UI runTripleValidation (val_star_required, gated on _starRunwayMap being non-empty) + server validateFlightObjects (missing_star, unconditional). Covered by tests/integration/save_gamecompat.test.js (8 tests) + gamecompat-utils.cjs arrival-no-star.
    0b. Voice/language repair (5) — same normalizer: the game's VoiceCatalog throws InvalidOperationException at level load when an aircraft's captain voice language != the flight's Language (fuzz-discovered at ZGSZ: an -EN captain voice on a zh flight). _loadVoiceCatalogForLevel(aclPath) reads <gameRoot>/GroundATC_Data/StreamingAssets/Voices/voice_catalog.json (name -> {language, role}); every flight whose Voice is unknown to the catalog or whose catalog language disagrees with Language is switched to a same-language captain voice (preferring one already used in the level). No-op when the catalog is absent. Mirrored by the renderer: airportValues._voiceLanguages (main.js _loadVoiceCatalog), pickVoiceForLanguage in flightDefaults.js, and val_voice_language_mismatch in runTripleValidation; the fuzz gate adds a voice-language-mismatch check. Airline/language repair (6) — same normalizer, runs BEFORE (5): the game's CallsignService allocates a callsign from the pool shared by the captain and ATC voices, and a callsign clip exists for exactly one captain language, so a flight's Language must follow its callsign's airline — CN carriers speak zh, every other carrier speaks en (CAL (TW) / CPA (HK) / ANA / AXM / MAS … are all en in the game's own levels). _loadAirlineCountryRegistryForLevel(aclPath) reads <StreamingAssets>/airline_country_registry.cfg (code → country) and _airportSupportsChinese(aclPath) checks for audio_clips_zh.json next to the level (a CN carrier only gets zh when the airport ships Chinese audio; KJFK/KDCA/EGLC have none). Mismatches set Language and bump relanguaged, then (5) repairs the Voice. A mismatch is what throws InvalidOperationException: Flight plan '<reg>' failed to allocate callsign '<cs>' for crew voice '<voice>' (CAL2017 / CN-Captain-Young). Mirrored by the renderer: airportValues._airlineCountries (main.js _loadAirlineCountries), languageForAirline/expectedLanguageForFlight (src/utils/airlineLanguage.js) used by createDefaultFlight, cascadeAirlineLanguage (Cascade 2b in appStore.updateFlight) and val_airline_language_mismatch in runTripleValidation.
  2. 7a-2. Pre-expand short-form $type refs: Expands bare $type: N references to fully-qualified strings before any cleanup steps that might remove type declarations. Uses per-segment type maps so each segment's own declarations are used. Bare refs surviving in KEPT entries are re-expanded in 7b-2.
  3. 7a-3. Pre-compute flight-plan $id map (fpIdByReg): Scans each GATCARC4 segment for its highest existing $id value — inflated to entryId + 38 for jetway: entries (a conservative ceiling covering every rebuilt jetway's sub-object range; all rebuilt sub-object $ids are now allocated from the segment's dynamic allocator seeded above this max) — then assigns flight-plan IDs at maxId + 1 + i for each registration (sorted). This fpIdByReg map is shared by both jetway rebuild (7b-1) and flight-runtime rebuild (7b-3) so they use identical $id values for _flightPlan entries. Jetway inline _flightPlan (emitted first in $rcontent) gets $id: fpId; the top-level flight-plan:REG entry (middle) becomes { __iref: fpId } pointing back to it; standalone aircraft (bottom) also use _flightPlan: { __iref: fpId }. All $iref refs are backward, so _reorderIrefEntries processes them with zero deferrals.
  4. 7b-1. Rebuild jetway entries constructively (_rebuildJetwayEntries): Replaces the modification-based _resetFrameJetwayDockingState with a constructive two-pass approach. Pass 1 parses all RuntimeEntities entries structurally (tokenizer), building a stand→flight lookup from DEP flights (when multiple DEPs share a stand, keeps the earliest OffBlockTime). Also builds a stand→arrivals lookup for turnaround detection: when a stand hosts both an ARR and DEP for the same aircraft (matching registration) and the arrival lands before the departure off-block, the jetway is left empty (aircraft hasn't arrived at the gate yet). For DEP flights on matching stands, _buildActiveJetwayEntry() generates a complete ~35-field Aircraft structure inside DockingAircraft from a hardcoded template. For stale/empty jetways, a clean cleared-state template is used. Claims shared canonical empty-array $ids — the six RunwayCoordinator string[] fields, AircraftEvent[] for _receivedEvents, ECommand[] for _waitingForCommands — from a per-segment dynamic allocator: the first aircraft entry that emits one defines it inline, every later entry $irefs it. Ids come from the shared allocator (seeded past the segment's max static $id incl. rebuilt-jetway expansion and past fpIdByReg ids), NOT from entryId+offset ranges — those overlap between adjacent jetways (entryIds 8 apart, template offsets up to 38: jetway:01 id(33)=36 equals jetway:02 id(25)=36) and a duplicate $id that is an $iref target crashes the game's JsonDataReader with a NullReferenceException. _buildActiveJetwayEntry registers old→new $id mappings in the IdMapper only for sub-object $ids that actually existed in the original entry (an empty jetway declares only entryId, Progress, DockingAircraft, DockingDoorIndex), so phantom entryId+4..38 mappings never collide with kept ids (radio-channel/singleton/other). When the original entry text is unavailable (defensive/test paths) it falls back to mapping every offset.
  5. 7b-2. Re-expand bare $type refs (belt-and-braces): Re-runs _expandShortFormTypes on segments modified by 7b-1. Since the DockingDoorIndex fix, rebuilt jetway entries inject full-form self-declaring "$type": "N|Name" values (resolved by canonical name against the segment scope) — no bare integers 3-6 are emitted. The pass survives as a safety net for bare refs remaining in KEPT entries, expanded against the 7a-2 pre-modification typeMap.
  6. 7b-3. Rebuild flight-plan, aircraft, and animator RuntimeEntities (_rebuildFlightRuntimeEntities): Deletes ALL existing flight-plan:REG, aircraft:REG, and aircraft-animator:aircraft:REG entries and rebuilds them from editor state in 4 passes — now using structured JS objects + serializeUnityJson() instead of template-literal string concatenation. Pass 1: remove old entries (stored as raw text to avoid lossy roundtrip), collect old $id for iref remapping. Pass 2: rebuild flight-plan:REG entries as structured objects with $type, __v for DateTime ticks, __fstrref for static-item references. Jetway-mapped flights (reg has a jetway entry) get $v: { __iref: fpId } instead of a full flight-plan object. Pass 3: build aircraft:REG entries via _buildStandaloneAircraftEntry using structured objects (spec, dynamics data, runway coordinator, position/direction — all JS objects with sentinel properties). Pass 4: build aircraft-animator:aircraft:REG entries. No more hardcoded ordering: all entries, including kept entries (raw text preserved), are wrapped in OdinEntry objects and passed through _reorderIrefEntries for topological sort by $iref dependency. Entries with no unresolved $iref targets emit in input order; entries waiting on a forward $id defer until it appears.
  7. 7c. Remove orphaned RuntimeEntities (_removeOrphanedFlightEntities): Removes/renames entries whose $k key (flight-plan:REG, aircraft:REG, aircraft-animator:aircraft:REG) references a registration that no longer exists in the rebuilt StaticItems. On registration rename, updates the $k value using the renameMap. Handles fallback via StaticItem.$fstrref for already-corrupted saves. Updates $rlength counters. Safe because aircraft $id namespace lives in jetway DockingAircraft (preserved by 7b-1), not in the orphaned aircraft:REG entries.
  8. 7d. Cleanup EventLog LatestEvents (_cleanupEventLogLatestEvents): On every save, fully clears the LatestEvents dictionary inside singleton:event-log.$v.LatestEvents — sets $rlength to 0 and clears $rcontent to []. with a full wipe, preventing stale aircraft:REG entries from accumulating across saves. Invisible to step 7c because it lives deeper inside singleton:event-log.$v.LatestEvents.$rcontent.
  9. Centralized $iref remapping (_IdMapper.remapIrefs): After all rebuild steps, applies old→new $id mappings collected by per-segment _IdMapper instances. Corrects $iref references in preserved entries that pointed to removed/rebuilt objects (e.g. flight-plan $id values that changed during 7b-3). The _IdMapper class acts as a union-find structure with transitive chain resolution and back-to-front positional replacement. Before remapping, every $id owned by a KEPT (non-rebuilt) RuntimeEntities entry is excluded from the mapper via _collectKeptRuntimeEntityIds (a string-aware structural scan of $rcontent, no regex) — jetway, radio-channel, singleton, and other entries keep their ids, so a rebuilt object's oldId that numerically collides with a kept id (e.g. an aircraft $iref:615 that points to a kept radio-channel stays 615 instead of being remapped to a rebuilt jetway id) is never rewritten.
  10. 7a. $fstrref cleanup & remapping (runs AFTER 7b): IndexOf-based positional scanning for $fstrref:"flight-plan:REG" references — replaces with null for deleted registrations, remaps to the new registration for renamed ones. Runs after 7b so the jetway rebuild step can detect stale DockingAircraft via intact $fstrref values before they are nulled.
  • Final safety net — ascending $id renumbering (renumberAclIds, in writeAcl): after ALL rebuild steps and remapping, every segment document gets its $id/$iref rewritten to a strictly ascending sequence in text order (see the "Binary container" bullet). This is required even when the rebuild's own allocation was correct: kept entries preserve their original low ids, which the construction-order allocator didn't know about, so the emitted text order can still descend.
  • Registration renames: When a flight's CallSign stays the same but its Registration changed, the pipeline detects this by matching old entries' CallSign (extracted from InitialArrival/InitialDeparture) to new flights' CallSign, building a renameMap that feeds into steps 7c/7a to avoid unnecessary data loss.
  • New constants: DEPARTURE_TAXI_SECONDS and ARRIVAL_TAXI_SECONDS in src/utils/constants/timing.js provide per-airport overrides for estimated OffBlockTime→Takeoff and LandingTime→InBlockTime durations. Values are medians measured from production saves — ZSJN: 464s dep / 372s arr, KJFK: 404s dep / 456s arr, default 400s both (fallback for airports without data, e.g. KDCA whose saves are clean starts). Measured via tests/_debug/extract_aircraft_times.js (decodes all .acl under Airports/<ICAO>/Levels/, extracts per-aircraft _departureTakeoffTime/_arrivalInBlockTime from RuntimeEntities + OffBlockTime/LandingTime from StaticItems → aircraft_times_report.tsv) then tests/_debug/compute_taxi_constants.js (per-airport median/avg, prints ready-to-paste constant blocks). Used by _computeTakeoffTicks / _computeArrivalInBlockTicks — lookup is TAXI_SECONDS[icao] ?? TAXI_SECONDS.default. TAXI_SPEED, POSITIVE_TAXI_ACCEL, NEGATIVE_TAXI_ACCEL (also from timing.js, re-exported via constants.js) supply aircraft dynamics values for _buildActiveJetwayEntry and _buildStandaloneAircraftEntry. DYNAMICS_POSITIVE_TAXI_ACCEL (3) / DYNAMICS_NEGATIVE_TAXI_ACCEL (-4) in src/utils/constants/aviation.js are the boosted dynamics override, verified against healthy game saves: DynamicsPositiveTaxiAcceleration/DynamicsNegativeTaxiAcceleration use them for final-approach aircraft (dynState === 2, aircraftState === 5 — these decelerate hard on the approach path) and fall back to the static (1/-2) values otherwise.
  • GRACE_TTL changed to -60: Aircraft that landed up to 60 seconds before the snapshot time are now filtered out of approach generation (was -10s). Gives more margin for aircraft that recently touched down, preventing them from appearing on approach with near-zero altitude.
  • Voice/language consistency validation: runTripleValidation (src/utils/validators.js) flags any flight whose Voice's voice_catalog.json language differs from its Language (val_voice_language_mismatch, zh+en) when airportValues._voiceLanguages is present — the game's VoiceCatalog throws InvalidOperationException at level load otherwise. Uses the same map that pickVoiceForLanguage (src/store/flightDefaults.js) uses to keep new flights consistent, and that FlightTable.jsx uses to filter the Voice dropdown to the row's Language (the current value is kept selectable when filtering would drop it, so a legacy mismatch never renders a blank select). Changing Language also cascades Voice to the first valid option for the new language (cascadeLanguageChange in flightCascade.js, applied as Cascade 4 in appStore.updateFlight, skipped when Voice is explicitly part of the same update). Airline/language consistency: runTripleValidation also flags val_airline_language_mismatch when a flight's callsign airline requires a different captain language (airportValues._airlineCountries present) — the game's CallsignService only records a callsign for one captain language (CN → zh, every other carrier → en), so a mismatch throws failed to allocate callsign … for crew voice … at load. New flights default Language from the airline (languageForAirline in src/utils/airlineLanguage.js, falling back to the airport region), and changing the airline cascades Language + Voice (cascadeAirlineLanguage, Cascade 2b in appStore.updateFlight). The FlightTable dropdowns read the registry directly: the Language cell is constrained to the airline-required language and the Voice cell filters by it (legacy current value kept selectable). MCP exposes get_airport_info.airlineLanguages (per-airport code → required language) + languageRules, buildConstraints carries airlineCountries/voiceLanguages/languages from the renderer's airportValues, validateFlightObjects rejects airline_language_mismatch, and applyCascades mirrors Cascade 2b. The fuzz gate (gamecompat-utils.cjs runChecks) reports airline-language-mismatch (fed airlineCountries + languages), and fuzz-save.spec.mjs generates the airline-required Language, cascades on airline change, and repairs the mismatch in its save-gate loop.
  • Scenario end +30 min grace for flight times: All three enforcement points — UI runTripleValidation (src/utils/validators.js), server validateFlightObjects (electron/api-server.js), and the clock-popover bounds getTimeValidationBounds (src/utils/timeUtils.js) — accept primary flight times up to _configEndTime + SCENARIO_END_GRACE_MIN (30 min, src/utils/constants/timing.js); the game allows events past scenario end. The lower bound _configStartTime stays strict, and timeline/runway-change bounds are NOT relaxed. get_airport_info reports the effective bound as timeRules.maxTime.
  • Blobdoc scope-aware $type expansion: The _expandShortFormTypes function was refactored into _expandWithBlobdocScopes (recursive scope-aware walker that identifies $blobdoc entries, extracts their type declarations, and recurses for nested blobdocs) + _replaceBareTypeRefs (single-scope regex expander). Each $blobdoc section has independent type numbering, so bare $type: N inside a blobdoc must expand using that blobdoc's own type declarations — not the outer document's.
  • GameTime.CurrentDateTime parsing: extractCurrentDateTime(aclText) tries GameTime.CurrentDateTime first (snapshot time, parsed from nested ticks), falls back to MetaData.BaseTime. Used by resolveConfigTime to override config startTime with post-warmup time (rounded UP to the next whole minute via roundSecondsToMinute(cdt.secSinceMidnight, true) — see timeUtils.js).
  • Structured object entry building: _buildStandaloneAircraftEntry, flight-plan entries, and animator entries now build structured JS objects with sentinel conventions (__v for bare values, __iref/__fstrref/__eref/__guidref for Odin references) rather than joining template-literal text strings. These are serialized via serializeUnityJson() which handles indentation, $type/$id ordering, and reference token emission. This eliminates the fragile string-formatting code that was error-prone to maintain. Type strings are now bare values (e.g. '7|ContextCross.Aircrafts.Aircraft, GroundATC.Core') — serializeUnityJson adds the surrounding quotes automatically.
  • $iref topological sort (_reorderIrefEntries + OdinEntry class): Uses a topological sort of all RuntimeEntities entries. OdinEntry is a lightweight wrapper around a parsed entry with lazy-serialized text, lazily-computed $id/$iref arrays. _reorderIrefEntries walks entries in a single pass: self-contained entries (all $iref targets satisfied by their own $ids or in externalIds) emit immediately; entries with unresolved $iref targets are deferred until the declaring $id is seen. externalIds is pre-seeded from the parent dictionary structure (comparer, etc.) so references to entries outside $rcontent are never misidentified as forward refs. Circular/missing $id references are logged as errors and appended as fallback.
  • Odin recursive-descent parser (parseOdinValue, parseOdinObject, parseOdinArray): A complete string-aware recursive-descent parser added to acl_json.js that handles the full Odin JSON dialect: typed-value objects ($type + bare values → __v), Odin reference tokens ($iref:N, $fstrref:"...", $eref:N, $guidref:GUID → sentinel objects), nested objects and arrays (recursively parsed), bare values (null, true, false, numbers, strings), and proper \uXXXX escape handling. No regex on the parse path for correctness. Used primarily by _reorderIrefEntries to extract $id/$iref metadata from kept entries — the entry's original text is preserved for output. Export through src/acl/parser.js.
  • Kept entries preserve raw text: Entries that pass through the rebuild (jetway, radio-channel, singleton, etc.) are stored as raw Odin text and emitted verbatim. This avoids lossy reconstruction of deeply-nested ReactiveProperty<Aircraft> structures where _fixTypedValues doesn't recursively handle all edge cases. Only metadata ($id/$iref values) is extracted via parseOdinObject — the text is never modified.
  • Pre-computed fpIdByReg — unified flight-plan $id across rebuild steps: Both _rebuildJetwayEntries and _rebuildFlightRuntimeEntities receive the same pre-computed fpIdByReg map, ensuring the jetway inline _flightPlan, the top-level flight-plan:REG entry, and the standalone aircraft's _flightPlan all share one canonical $id per registration. The pre-computation scans each segment for maxId and assigns IDs at maxId + 1 + i for sorted registrations, guaranteeing no collision with existing entries. nextId is advanced past the pre-computed flight-plan IDs before aircraft/animator entries are built.
  • Shared empty-array $iref dedup via dynamic allocator: The sharing (rpSharedArr/rpStrArr in _buildStandaloneAircraftEntry, sharedEmptyArrayInner/sharedStrArrInner in _buildActiveJetwayEntry) is safe because canonical $ids are claimed from the segment's dynamic allocator BEFORE any entry text is emitted, so they always precede the entries that $iref them (backward refs only — zero deferrals in _reorderIrefEntries). One canonical empty AircraftEvent[] / ECommand[] / string[] per segment: the first emitter writes the inline definition, every later entry emits a bare $iref. Non-empty content (State=5 _waitingForCommands = [CMD_CONTACT_TOWER]) is never shared — it stays inline at its own id and does not claim the canonical definition.
  • fpTypeStr regex tightened: The $type extraction in _rebuildStaticDataSections now only accepts the expanded "N|TypeName" form. A bare "$type": N would not self-register in the blobdoc scope, causing "unknown type id N" during Odin binary encoding. The default fpTypeStr is already the correct expanded form via per-file typeMap lookup.
  • Preprocessor additions: The preprocessor gained passes for: (0) _fixOdinReferences — string-aware transformation of $iref:N, $fstrref:"...", $eref:N, $guidref:GUID → sentinel objects for JSON.parse; (1.5) _fixMissingCommas — inserts missing commas between properties; _collectBareValues now handles null/true/false as bare values and recursively fixes typed values inside nested objects and arrays.
  • SPECIAL_KEYS updated: Added __iref, __fstrref, __eref, __guidref to the set in src/utils/constants/acl-format.js.
  • Strict type resolution from segment type maps — with scoped exceptions: ALL type numbers come from the per-segment type map (segTypeMap / jwTypeMap) — no hardcoded ids anywhere. _makeJwTypeResolver is the single shared resolver: _buildStandaloneAircraftEntry and _rebuildFlightRuntimeEntities use unquoted mode (bare N|Name, the serializer quotes it), _buildActiveJetwayEntry the quoted full-form mode. This prevents type-id-reclamation collisions with kept entries already expanded to full form by _expandShortFormTypes. STRICT by default: a missing canonical name throws [TYPE-ASSERT] with the name, ctx, and a full sorted scope dump (no truncation). Exception 1 (fuzz-verified): approach-dynamics types are resolved LAZILY / CONDITIONALLY — a departure-heavy scope that legitimately never declares ApproachDynamicsParams must not crash the save. _buildStandaloneAircraftEntry gates State=5 emission on scopeDeclaresApproachDynParams (scan of declared names) — State=5 degrades to STAR State=30 when the scope can't represent final approach. _buildActiveJetwayEntry never resolves DYN_PARAMS_TYPE (the jetway path emits no approach dynamics). Exception 2 (v5 ZGSZ): genuinely missing jetway/standalone types get a fallback allocation — allowFallback:true mints maxId+1 and self-declares via full-form "$type":"N|Name" (see jetway $type bullet). STRICT_JETWAY_TYPES remain strict. Exception 3 (lean checkpoint-frame scope): _rebuildFlightRuntimeEntities's shared segTypeResolve (the fp/dt/animator resolver) also passes allowFallback:true — a checkpoint frame that legitimately carries only jetway/flight-plan runtime entities omits the aircraft/animator type graph, so the eager animTypeFull = segTypeResolve('ContextCross.Models.AircraftAnimator, GroundATC.Core') would [TYPE-ASSERT] and fail the whole save. Fallback mints a fresh id and self-declares it in the emitted full-form $type (see the animator $type bullet). The standalone-aircraft catch also logs the FULL [TYPE-ASSERT] message (canonical name + scope dump) instead of truncating to the first line. The old _freshNum counter is gone.
  • No hardcoded type numbers — resolve, or re-declare when stripped: _rebuildStaticDataSections resolves blobdoc-scoped types (DateTime, FlightPlanArrivalLeg, FlightPlanDepartureLeg, FlightPlanStaticItem) via _bdTnOrAlloc(search, label, fullName) — it searches the blobdoc scope first, falls back to the file-global map only when the matching number is unclaimed in the blobdoc (a checkpoint frame can claim the same number for a different type), and when neither declares it, allocates the next unused blobdoc-scope id and emits the expanded "N|Full.Name, Assembly" form (which self-registers) instead of throwing. This is what fixes the KJFK_peakarrival fuzz: an all-departure save emits no arrival leg, so the game strips FlightPlanArrivalLeg from the blobdoc type table; a later save that reintroduces an arrival aborted with blobdoc type "…" not in bdTypeMap. Applies to DateTime/FlightPlanDepartureLeg/FlightPlanStaticItem too. Lazy exception: each type resolves only when a leg/entry that needs it will actually be emitted (hasFlights / hasArrivals) — a 0-flight or all-departure save strips unmatched types and must not resolve them. In approach.js, type keys resolve via resolve(key, fullName) that throws if the key is missing from typeNums. Hardcoded fallback defaults (e.g. 33 for AircraftState) are eliminated entirely — types must be present in the per-file type map or the build fails early with a clear error.
  • Approach-builder type numbers from per-file typeNums (never hardcoded): buildApproachAircraftBlock and buildState5AircraftBlock resolve every $type — including vec3 (UnityEngine.Vector3) and strArr (System.String[]) — from the per-file typeNums map. The old hardcoded "16|UnityEngine.Vector3, UnityEngine.CoreModule" / "8|System.String[], mscorlib" were wrong for real files (ZSJN_leisure_1 declares Vector3 at 5 and String[] at 10), and BCL ids are not stable across Unity versions. Missing keys throw via resolve(key, fullName).
  • Base-time extraction is $type-agnostic: _extractBaseDateFromText matches "BaseTime": { "$type": <any-id>, ticks } and WorldState flight times with "\$type": \d+ — DateTime ids are per-scope, so a hardcoded $type: 3 silently missed files that declare DateTime elsewhere.
  • CANONICAL_SCOPE fixture for builder unit tests: tests/integration/_canonical_scope.cjs exports the ZSJN_leisure_1 frame-scope type declarations (ids 0–51). Unit tests that drive _buildActiveJetwayEntry/_buildStandaloneAircraftEntry must pass it as jwTypeMap/segTypeMap — strict resolution asserts [TYPE-ASSERT] on any canonical name the scope doesn't declare, so new Map() no longer works. Derived via tests/_debug/dump_frame_scope.cjs; duplicate names keep last-in-text-order semantics (AircraftEvent[] at 1 and 28 → resolves to 28).
  • Missing DockingPositions — hard error pre-v5, fallback post-v5: Pre-v5 both _buildStandaloneAircraftEntry and _buildActiveJetwayEntry threw when spec.DockingPositions was missing/empty (instead of silently falling back to [{ x: -1.742, y: 2.68, z: 14.75, w: 90 }]). Post-v5 the missing-spec path fabricates a full fallback spec that includes a valid DockingPositions = [{x:2.5,y:0,z:0,w:1}], so this hard error only fires when spec.DockingPositions is present but genuinely empty — the !spec case is now the [SPEC-FALLBACK] path.
  • Spec fields are REQUIRED — requireSpecField with fallback spec (v5): requireSpecField(log, ctx, field, value, spec, fallback) (in src/acl/approach.js, exported) asserts when a resolved field value is missing. The fallback spec itself is built from DEFAULT_* (see spec-fallback bullet) and requireSpecField validates the fabricated spec's fields — so DEFAULT_* are both diagnostic and emitted values for the missing-spec path. Applies on intake (extractSpecificationDB via CSV primary) and on emission (buildApproachAircraftBlock, buildState5AircraftBlock, _buildStandaloneAircraftEntry, _buildActiveJetwayEntry). The assert message still names the exact failure chain: builder, registration, AircraftType, designator, lookup trace (designatorMap[x]=y; direct specDB[x]=hit|MISS), refused fallback, and a raw spec preview — but for the fallback path the fallback is used, not refused.
  • Spec fallback — generic defaults (reverses strict throw): The strict [ACL-ASSERT] throw for missing specs is replaced by an explicit fallback that keeps the departure docked instead of emptying the stand. Both _buildActiveJetwayEntry and _buildStandaloneAircraftEntry now fabricate a spec from DEFAULT_* constants when specDB/designatorMap (including the global CSV) miss — Designator = designator || AircraftType || 'B738', AerodromeCode = DEFAULT_AERODROME_CODE (67='C'), WheelBase 12 (jetway) / 10 (standalone), WingSpan 36, RunwayVRSpeed/ToL = DEFAULT_*, ModelOffset = DEFAULT_MODEL_OFFSET, DockingPositions = [{x:2.5,y:0,z:0,w:1}] — and log [SPEC-FALLBACK] with the lookup trace (designatorMap[x]=y; direct specDB[x]=MISS). Only the !acType (no AircraftType at all) path still throws [ACL-ASSERT]. The stale-jetway spec aggregation now dedupes per-AircraftType (jwSpecFailCounts + console.debug per stand + one [ACL-REBUILD-V4] jetway rebuild: N stand(s) … info line). Standalone failures are caught per-aircraft in _rebuildFlightRuntimeEntities (standaloneFailCounts → one aggregated log, flight-plan preserved, jetway empty) so a single bad type never aborts the whole save. This covers ZGSZ (0 runtime specs) and rare types like COMAC C-919/A320ceo variants that have no runtime representation in any level — the generic dimensions are sufficient for the game to load.
  • AerodromeCode from spec data (never hardcoded 67): The game uses AerodromeCode (ASCII ICAO aerodrome reference code: 67='C' narrowbody, 69='E' widebody, 70='F' 748/388-class) for stand/jetway compatibility. The emitted value is spec.AerodromeCode resolved through the normal spec chain — a spec missing it asserts via requireSpecField (refusing fallback 67).
  • Every rebuilt jetway sub-object $id from the segment's dynamic allocator: In fails.acl, jetway:09 id(15) = 190 + 15 = 205 equals jetway:12 id(3) = 202 + 3 = 205; a duplicate $id that is an $iref target makes the game's JsonDataReader bind the first declaration (first-wins), so aircraft:B-5380A's $iref:205 resolved to a String[] wrapper → "Data layout mismatch; skipping past node boundary when exiting array". Now _buildActiveJetwayEntry allocates every sub-object id from the segment's dynamic allocator (shared with the canonical-array caches, seeded past all static/flight-plan/canonical ids) and registers each old entryId+offset id in the segment _IdMapper, so the centralized remap step rewrites $iref references in preserved entries. The no-cache/defensive path derives a segment-wide allocator past the segment's max $id.
  • Jetway field $types resolved by NAME (_makeJwTypeResolver) — fallback for genuinely missing types (v5): Type ids are per-blobdoc and vary between files — R3.ReactiveProperty<Int32> is id 6 in ZSJN_leisure_1 but id 29 in ZSJN_taixwayclosed, where hardcoded id 6 means ContextCross.Aircrafts.Aircraft (writing id 6 for DockingDoorIndex misbinds it). _makeJwTypeResolver(jwTypeMap, quoted, opts) (exported from flight_plans.js) builds a name→id lookup from the segment's declarations and returns full-form "N|Name" strings (quoted=true for template text, quoted=false bare N|Name for the serializer). STRICT by default: a missing canonical name throws [TYPE-ASSERT] "name" context={reg,acType,stand,…} Scope declarations (N): … with a full sorted dump (no truncation — truncated 30-entry dumps hid ZGSZ's missing type). v5 fallback: opts={ctx, allowFallback:true, typeAlloc:{v}} allocates a fresh id maxId+1 beyond the segment's max and self-declares via full-form "$type":"N|Name" — safe because the emitted full-form IS the declaration and the id never collides. STRICT_JETWAY_TYPES (Jetway, R3.ReactiveProperty<Single/Aircraft/Int32>) never fallback-allocate. _buildStandaloneAircraftEntry calls with allowFallback:true; _buildActiveJetwayEntry wraps the shared jwTypeResolve with a fallback resolver that retries on [TYPE-ASSERT] and reflects the allocation back into jwTypeMap so siblings see it. One resolver instance per segment is shared by ALL rebuilt entries (active + empty). Top-level RuntimeEntity fields resolve the same way. ApproachDynamicsParams is NOT resolved here (dead eager DYN_PARAMS_TYPE removed — crashed departure-only saves; the jetway path never emits approach dynamics).
  • State=5 aircraft in standalone entries: _buildStandaloneAircraftEntry now supports State=5 (final approach, DynamicsState=2) aircraft. When aircraftState === 5, the entry gets ApproachDynamicsParams instead of FlyApproachDynamicsParams, with PathPointList, TouchDownPosition, ApproachDirection, and InitialPosition fields. State=5 params are resolved from approachCache.state5ParamsMap by appKey then runway — throws a descriptive Error when no cached entry exists (state5ParamsMap must always be populated at cache-build time). Scope gate: the rawTargetDist >= flyLen State=5 decision also requires scopeDeclaresApproachDynParams — when the segment's type table omits ApproachDynamicsParams (departure-heavy files), the aircraft stays State=30 instead of throwing [TYPE-ASSERT] at save time (found by the fuzz test on ZSJN_peakdeparture: fuzz-added arrivals mid-final crashed a save).
  • State=5 CMD_CONTACT_TOWER in waiting commands: State=5 (final approach) standalone aircraft entries now include CMD_CONTACT_TOWER in their _waitingForCommands array (__v: [{ $id: id(25), $type: T.cmdArr, $rcontent: [CMD_CONTACT_TOWER] }]), giving the arrival an initial "contact tower" command. selectedRunwayExitIndex is 0 (was -1 in earlier versions). State=30 (before IAF) and State=10 (DEP parked) entries keep an empty $rcontent: []. (State=5 is only emitted when the scope declares ApproachDynamicsParams — see the State=5 bullet.)
  • DEP aircraft in RuntimeEntities: DEP flights now generate aircraft:REG + aircraft-animator:aircraft:REG pairs during _rebuildFlightRuntimeEntities. For DEP flights with an active (populated) jetway, the aircraft entry is $v: { __iref: jetwayAcId } — pointing into the jetway's inline DockingAircraft — avoiding duplication of the large Aircraft object. For DEP flights without a jetway (or on a stale jetway), a standalone Aircraft entry is built with taxi values (speed, acceleration) set to 0 (parked aircraft), DynamicsParams: null, aircraftState: 10 (parked-at-stand), and gearRatio: 1 (gear down). The radioChannelId reference defaults to null when no radio-channel entry exists. Standalone DEP aircraft still get a position from _resolveStandPosition.
  • FlightPlan unified via $iref chain: The _flightPlan field in _buildStandaloneAircraftEntry is now always { __iref: fpId } — a thin reference to the flight-plan $id rather than an inlined copy of the full FlightPlan object. The canonical FlightPlan data lives in one of two places: (a) inside the jetway entry's inline DockingAircraft._flightPlan (jetway-mapped flights), or (b) in the top-level flight-plan:REG entry (standalone flights). The fpId is pre-computed in step 7a-3 and shared across both rebuild steps, so the $iref chain is always valid. All refs are backward (jetway emitted first, then flight-plan entries, then aircraft entries), guaranteeing zero deferrals in _reorderIrefEntries. Reference-integrity regression (tests/integration/flightplan_ref_integrity.test.js): the jetway reconciler is drop-only — a fabricated runtime Jetway (addMissing) for a static jetway that had no snapshot would let that $id (and the sub-ids the empty-jetway rebuild generates) land in the fpIdByReg flight-plan id block on a purge save, so renumberAclIds binds aircraft:REG._flightPlan to the jetway's DockingAircraft — Unity: InvalidOperationException: Aircraft 'aircraft:REG' has no flight plan reference (ZGSZ_leisure_2 aircraft:B-05Z7, KDCA_leisure_2 aircraft:F-TGSN, ground fuzz). The regression test drives patchSceneryBlob → writeAcl → generateFullAcl on the _debug ZSJN level and asserts (1) no runtime Jetway is fabricated and (2) the saved RuntimeEntities scope has unique $ids and every aircraft:*._flightPlan $iref targets its own flight-plan:<REG>.
  • Combined aircraft+animator pair building (PASS 3&4 merged): PASS 3 (aircraft:REG) and PASS 4 (animator:aircraft:REG) are combined into a single loop producing acPairs — each element is a [aircraftObj, animatorObj] tuple. This guarantees aircraft and animator entries for the same registration are always adjacent in the output, preventing forward $iref issues when the $rcontent order changes. The pair array is flattened into the ordered descriptor list.
  • Partitioned kept entries: Kept RuntimeEntities entries are now partitioned by $k prefix into five buckets: jetway: → keptJw, radio-channel* → keptRadio, singleton:* → keptSingleton, and everything else → keptOther. Final assembly order is deterministic: jetway → radio-channel → flight-plan (new) → singleton → aircraft+animator pairs → other. All kept entries preserve raw text and only extract $id/$iref metadata via parseOdinObject.
  • typeNameIndex in approachCache: buildApproachCache exports typeNameIndex (inverted typeMap: name→id) and fileTypeNameIndexes (per-file name→id Maps). Built by buildTypeNameIndex(typeMap) in approach.js — first declaration wins per Map.
  • Shared canonical-array caches (strArrCache/recvEventsCache/waitingCmdsCache): Both _rebuildJetwayEntries (7b-1) and _rebuildFlightRuntimeEntities (7b-3) receive per-segment cache objects for the shared empty arrays — string[] (the six RunwayCoordinator fields), AircraftEvent[] (_receivedEvents), ECommand[] (_waitingForCommands). All three caches share ONE dynamic $id allocator per segment ({ v } seeded at segMaxId + 1), so their canonical ids can never collide with each other, with static entry ids, or with precomputed flight-plan ids. canonicalId (claimed once, by whichever of 7b-1/7b-3 runs first) plus canonicalEmitted (whether the inline definition has been written yet) handle the cross-ordering between the two rebuild steps. 7b-3 also advances nextId past every claim so aircraft/animator ranges never reach them.
  • Radio-channel type resolution from StaticItems: During _rebuildFlightRuntimeEntities, the code parses StaticItems.$rcontent via the tokenizer to build a radioChannelTypeMap (key → Type: 2=GND, 3=TWR, 5=APP). Kept radio-channel entries in RuntimeEntities are matched by key against this map to identify separate towerChannelId (Type 3) and apprChannelId (Type 5).
  • State-dependent radio channel selection: _buildStandaloneAircraftEntry selects between tower (State=5, final approach) and approach (State=30, before IAF) based on aircraft state. State=10 (DEP parked) stays null. Both _radioChannel and _jurisdictionRadioChannel are set to the same active channel.
  • gearRatio in animator blocks: buildAnimatorBlock accepts a gearRatio parameter (default 1). Gear is down (1) for parked (State=10) and final-approach (State=5) aircraft; gear is up (0) for STAR approach (State=30, before IAF) since landing gear retracts during cruise/descent phase.
  • TouchDownPoint $iref resolution: resolveApproachProcedureData resolves touchdown position from the runway entry's dedicated TouchDownPoint $iref pointer instead of extending past the last approach-procedure point by a fixed distance. The $iref targets a taxiway-node entity at the landing threshold. Throws on missing or unresolvable TouchDownPoint.
  • routeName in approach procedure data: resolveApproachProcedureData extracts and returns the Name field from the approach procedure's route entry as routeName. _buildStandaloneAircraftEntry uses this routeName for the ApproachRoute field.
  • State=5 _route uses approach procedure name: In _buildStandaloneAircraftEntry, State=5 aircraft (final approach) set the _route field to state5Params.routeName (e.g. "RNAV ILS Z Rwy 19") instead of the STAR name (e.g. "UBSS6W"). Non-State-5 entries keep star; the guard is aircraftState === 5 && state5Params && state5Params.routeName. The jetway DockingAircraft template is unaffected (jetway aircraft are always State=10 parked).
  • hintPosition for approach variant selection: computeFullTerminalPath passes the last FlyApproach point (IAF) as hintPosition to resolveApproachProcedureData. When multiple Type=1 approach procedure variants exist for a runway, the closest one to the hint position is selected instead of the first variant. Without this, TAT could be misestimated and State=5 aircraft positions could be incorrect when variants have different path lengths.
  • totalLen includes touchdown distance: _buildStandaloneAircraftEntry includes the touchdown distance in totalLen (the denominator for rawTargetDist IAF boundary). This ensures totalLen matches the path length that TAT was calibrated for — scenery-derived TAT includes tdDist from computeFullTerminalPath. Without this, rawTargetDist used a shorter denominator, biasing the IAF boundary toward State=30 for aircraft near the runway.
  • Hardcoded InitialPosition.Y: _buildStandaloneAircraftEntry hardcodes InitialPosition.Y to 15.24 (the standard ILS approach ceiling at 100 m/unit scale = 5000 ft) instead of using state5Params.initialPosition.y || 0. The stored path points have Y=0 (game engine computes altitude internally from touchDownPosition + path distance), but InitialPosition stores the approach ceiling altitude directly. Every original game file uses 15.24 regardless of airport.
  • touchDownPosition from state5ParamsMap: _buildStandaloneAircraftEntry resolves touchdown position from approachCache.state5ParamsMap (by appKey then runway) instead of approachCache.runwayThresholds. runwayThresholds returns an array of two threshold positions, not a single touchdown point — passing it to computePosition caused _vec3Dist to return NaN (undefined.x - undefined), so the touchdown segment was never appended to the path and glideslope Y was wrong.
  • v4 Vector4 array parsing via preprocessUnityJson: _extractVector4Array uses preprocessUnityJson + JSON.parse with the __v sentinel for v4 files. This correctly handles both short-form ("$type": 11) and full-form ("$type": "11|UnityEngine.Vector4,...") type strings in Vector4 array entries, ensuring DockingPositions and similar arrays decode accurately.
  • Timeline rebuild via MetaData object (_rebuildV4TimelineSections): _rebuildTimelineSections(aclPath, weather, wind, runway) parses the MetaData section value with the tokenizer, splices rebuilt WeatherFrames / WindFrames / RunwayTimeline sub-sections in place (keyStart → valueEnd, keeping the "MetaData" key intact), and writes back via writeAcl. Mirrors the load → modify → serialize pattern. In v4 files these timeline sections live inside MetaData, not at top level.
  • Weather/wind frames are level-wide — never window-filtered: A weather/wind frame governs the level from its time onward, so a single frame outside [Config.startTime, endTime] still controls the whole level; shipped levels ship such frames (ZSJN_leisure_1: window 05:39:14–06:15:00 with wind at 05:00:00, weather at 09:00:00, and WeatherFrames spanning 06:00–24:00). Consequently WeatherEditor.jsx / WindEditor.jsx always render all frames, their TimeCells have no min/max bounds, and save validation never checks weather/wind times. Only RunwayTimeline is window-bounded by design (auto-pruned on load in EditorScreen.jsx, val_runway_change_bounds at save). The .tl-list CSS grids for weather/wind are display: contents grids — header and rows must emit exactly the template's column count (5 for #weather-list, 6 for #wind-list) or cells cascade into the wrong columns. Frame schema: WeatherFrame = { Preset: string, Time: "HH:MM:SS" }, WindFrame = { Direction: int, Speed: int, Time: "HH:MM:SS" } (see references/acl-format.md).
  • RunwayTimeline type-number conflict fix: When a file's Timeline is empty, _metaRunway falls back to tlTypeNum + {1,2,3} for the element type numbers — but those may already be claimed by unrelated types in the outer document (e.g. ZSJN files: type 12 = System.Byte[], mscorlib collides with the tlElemTypeNum = 12 fallback when tlTypeNum = 11). The rebuild path scans the full document for used "$type": "N|" numbers and bumps each conflicting fallback to the next unused number, emitting the full-form declaration on the first element and bare refs after. Verified on ZSJN: 12/13/14 → 55/56/57.
  • Tokenizer findKeyAtDepth string-awareness fix: findKeyAtDepth(start, end, key, targetDepth) only matches the search key at an opening quote that is OUTSIDE a string (!inString at "), instead of checking for the key text anywhere at the target depth. The key-match check moved into the quote handler before the inString toggle; escaped quotes (\") are still skipped by the backslash rule.
  • State=5 ProgressRatio=0: _buildStandaloneAircraftEntry writes constant ProgressRatio: 0 (STATE5_OUTPUT_PROGRESS_RATIO) for State=5 (final approach) aircraft — the game recalculates path-based PR from PathPointList. Position/direction are still computed with the real time-based PR. State=30 entries keep their computed PR. Covered by tests/integration/state5_output_pr.test.js.
  • New flights: isDeparture flag + AirlineName code: Newly created flights (UI factory createDefaultFlight, MCP create_flights) now carry isDeparture (true for departures) and AirlineName = the 3-letter airline code (the game stores codes, e.g. "CDG"/"UAL", not display names). All 7 arrival/departure classification sites in flight_plans.js (static-items leg, stand-conflict validation, jetway rebuild, turnaround winner, runtime flight-plan/aircraft entries) go through _isDepartureFlight() — isDeparture === true OR non-empty OffBlockTime — so a missing flag can never silently serialize a departure as an arrival again. The serializer also falls back AirlineName to the callsign prefix. AirlineCode edits sync AirlineName via the cascade (renderer flightCascade.js + server applyCascades). Covered by tests/integration/new_departure_save.test.js (saves flag-less flights, asserts the departure leg + code roundtrip).

Key Rules for Agents

  1. React + Vite + zustand stack. Frontend uses ESM, JSX, and React hooks. No global-scope scripts.
  2. No TypeScript. This is plain JS/JSX. Do not add tsconfig.json or convert files to .tsx.
  3. No linter/formatter. Do not add ESLint, Prettier, or any linting config unless explicitly asked.
  4. Testing uses Vitest (component) + Playwright (E2E) + Node.js (integration). Component tests go in tests/components/, electron-backend tests in tests/electron/ (use @vitest-environment node + require.cache priming for CJS modules that require ESM SDKs), pure backend/util + hook tests in tests/acl/ and tests/unit/, E2E specs in tests/e2e/, integration scripts in tests/integration/. Do not add Jest or Mocha.
  5. No npm dependencies for core logic. The app uses only Node.js built-ins. Justify any new dependency.
  6. Preserve CommonJS for backend. electron/ and src/acl/ use require()/module.exports.
  7. ESM for frontend. src/components/, src/hooks/, src/store/, src/utils/ use import/export.
  8. IPC for all file I/O. The renderer never touches the filesystem. All reads/writes go through electron/main.js handlers.
  9. Return { success } from IPC. Every handler returns { success: true/false, error?: string }.
  10. _underscore = private in backend. Prefix internal functions with _. Export them anyway for testing.
  11. snake_case.js for backend, PascalCase.jsx for components. Match existing conventions.
  12. No inline style={{}}. Always extract CSS to the component's .css file.
  13. One .css per component. Match the component filename.
  14. No dangerouslySetInnerHTML. Use safeHtml() from src/utils/safeHtml.jsx to render i18n strings containing <strong>, <em>, or <br> as safe React nodes. It sanitises unknown tags and attribute injection as plain text.
  15. Use readAclText() / writeAcl() for all ACL I/O. Never use fs.readFileSync(path, 'utf-8') or fs.writeFileSync(path, text, 'utf-8') for .acl files. Always go through gatcarc.js to transparently decode/encode the GATCARC4 binary container. This applies to electron/main.js, all backend modules, and all integration tests.
  16. Use resolveConfigTime() for config time display. Every consumer of ACL config time (startTime/endTime) must use resolveConfigTime(rawText) from src/acl/config.js — not _extractConfig() directly. resolveConfigTime applies the GameTime.CurrentDateTime override (post-warmup time), rounded UP to the next whole minute via roundSecondsToMinute() from src/utils/timeUtils.js. This is the single source of truth. All IPC handlers that read config (get-file-info, load-acl, save-acl, import-zip, restore-latest-backup, load-timelines) already use it.
  17. Update the facade. New backend modules must be re-exported through src/acl/parser.js.
  18. Build locally with node build.js on Windows, never npm run build:win (local only — CI uses npm run build:win for cross-platform builds).
  19. Bump CACHE_VERSION when cache.json schema changes. Any change to the structure of approachData, fileTypeMaps, state5ParamsMap, taxiwayPaths, sidPaths, missedAppPaths, airwayNodes, starWaypoints, allAclTexts merge, or new top-level keys in cache.json MUST bump CACHE_VERSION in src/utils/constants/timing.js (re-exported via src/utils/constants/index.js and src/acl/constants.js for CJS backward compat). Stale caches silently corrupt saves. Note: saveTimeOffsets and typeMap are build-time-only and are no longer serialized.
  20. Version tags for releases. Version tags use v<MAJOR>.<MINOR>.<PATCH> format. Keep package.json version and the git tag in sync. The user decides when to bump; you can re-tag the same version to include new changes.
  21. Keep documentation in sync. After any significant change, update ALL of:
  • This skill (.claude/skills/ac27-editor/SKILL.md) and its reference files
  • README.md
  • tests/README.md — whenever tests are added/removed, update the test counts (line 9, line 18, line 22), the file table (add/remove rows), MapWindows file/test counts (line 34), and expected outcomes (lines 44–53). Stale test docs mislead contributors about what's covered.
  1. UDP listener lifecycle is managed by main process. startUdpListener() is called in app.whenReady() after createWindow(), stopUdpListener() in will-quit. The listener auto-reconnects on socket errors (2s delay). Do not create multiple listeners or start/stop from the renderer.
  2. Map windows are separate BrowserWindow instances. They are NOT React components in the main renderer. Track them in groundMapWindows/airMapWindows/flightStripsWindows Maps (keyed by ICAO). Always check for existing windows before creating (focus if exists). Clean up Map entries in the closed event handler. Each window loads the same Vite SPA with query params (?window=groundMap&airport=XXXX, ?window=airMap&airport=XXXX, or ?window=flightStrips&airport=XXXX).
  3. UDP state push handles cleanup. The udp-aircraft-state IPC event is pushed to ALL open map windows every 200ms. Map window components subscribe via useUdpAircraftState() hook which wraps onUdpAircraftState/offUdpAircraftState. Always unsubscribe in useEffect cleanup to prevent stale callbacks or memory leaks.
  4. Skill represents latest state only — no editor version or datetime pinning. This skill and its reference files must describe only the current behavior of the software. Do not record editor version numbers, CACHE_VERSION values, package versions, tag examples, release dates, or datetimes. When a value changes, update the description in place instead of preserving the old value as history.