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.
Resources
1Install
npx skillscat add ericpzh/ac27leveleditor/ac27-editor Install via the SkillsCat registry.
AC27 Editor — Repo Skill
Project Identity
- Name:
ac27-editor - Purpose: Cross-platform desktop level editor for Airport Control 27
.aclflight 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-mcpskill) - 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.jsmanagesgroundMapWindows/airMapWindows/flightStripsWindowsMaps (keyed by ICAO) +selectedCallSignsMap (synced selection state)Each map window loads the same Vite SPA with query params (
?window=groundMap&airport=XXXX, etc.)electron/udp_listener.jslistens on127.0.0.1:20266for binary aircraft telemetry (10 Hz) and sends commands on127.0.0.1:20267Live aircraft state pushed to all open map windows (ground + air + flight strips) at 200ms interval via
udp-aircraft-stateIPC eventMap window click-to-select goes through centralized
select-aircraft-in-mapIPCRadar 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/15all the same STAR base) —dedupeStarPathsForDisplaymerges 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. ThenfilterDedupedStarPathsByRunwayfilters merged groups + preserved per-runway variants by the active runway set. GroundMapWindow computes an auto-fitcontentViewBoxover 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 toGROUND_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 viaatan2(noseDirection.x, noseDirection.z), no fixed value list),Fly Altitude(1000-ft slider, hidden without live telemetry; ChinaZ*airports —isChinaIcao, the same Z-conventionflightDefaultsuses 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 sendsupdate_headingat 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), orClear for Approach(supersedes the whole composed chain). Each panel's Add chains commands; Send dispatches the chain IN ORDER — N sequentialsend-patch-commandframes to the AC27Approach plugin (see theac27-approachskill). Heading/waypoint frames carry a fixed rate of 3°/s of game time (TURN_RATE_DEG_S— the plugin'supdate_headingsmoothing; forclear_for_apprit 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 oncheckCommandCapability/checkBepInEx(plugin only exists with BepInEx installed) +witchModeoff; Escape/Cancel dismisses. Approach-only: aircraft handed to tower (seat 3) lose the popup automaticallyVoice command input (live, patch-command vocabulary):
useVoiceCommands.js+VoicePTTButton.jsxprovide push-to-talk voice commands for the Flight Strips window. Callsign-first flow: spoken airline name → ICAO code (viaAIRLINE_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 byelectron/voiceSttWorker.jsviaprocess.execPath+ELECTRON_RUN_AS_NODE=1), mic capture by sox (bin/sox, default recording device, 16 kHz mono), decoding by koffi + vendoredbin/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 inreferences/map-windows.md"Voice Command Input";scripts/fetch-vosk-model.mjsdownloads the models (gitignored);scripts/voice-stt-test.mjsis 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, norequire('electron')— unit-tested intests/electron/ptt-shortcut.test.js) holds an OS-levelglobalShortcutregistration (defaultShift+Space, persisted aspttShortcutinac27-config.jsonand re-registered at startup — nevercache.json, which Restore All deletes) that broadcasts the hold-to-talk edgesglobal-ptt-down/global-ptt-upto the focused strips window, which starts/stops itsuseVoiceCommandssession on the edge.electron/pttKeyWatch.js(koffi →user32!GetAsyncKeyState, Windows-only, unit-tested intests/electron/ptt-key-watch.test.js) supplies the key-UP thatglobalShortcutlacks 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 legacyglobal-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 viaPttShortcutModal.jsx(title + live key echo only; Backspace disables; must be Modifier+Key or F1–F12 — Windows can't register bare keys); accelerator helpers insrc/utils/pttShortcut.js(tested intests/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 (thecardssection + the Debug Mode settings row, flaggedmodOnlyinBrowserHelpOverlay) 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 flagradarModWarnDismissed(new key →CACHE_VERSIONbumped). 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 ischeckCommandCapability()— the deployed DLL's MD5 vs the release reference (bundled Workshop DLL on Steam/Workshop viaresolveWorkshopBundledDllPath(); 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) theninstallApproachDll(), with a file-picker fallback. Dev (!app.isPackaged) never enforces the MD5, sonpm start/npm start steamdon't nag. The Flight Strips window no longer re-runscheckCommandCapabilityand has no install/update overlay (commandCapableis treated as true — the browser already guaranteed the mod); the legacyApproachPluginInstallOverlay/handleLoadDll/STORAGE_KEY_APPROACH_PROMPTprompt code is now unreachable.Level reset:
BrowserScreen.jsxheader 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 callsreset-all-levelsIPC (electron/main.js:reset-all-levels,electron/preload.js:resetAllLevels(rootPath)) which deletes everyGroundATC_Data/StreamingAssets/Airports/XXXX/Levelsentry (files + subdirs, including.bak) FIRST and only then removescache.json(stale STAR/SID/approach caches, ground anchors, file lists).gameRootresolves as explicitrootPatharg (renderer store) → fallbackcache.jsongameRoot→NO_GAME_ROOT; the explicit arg keeps restore working when the cache is missing/lang-only and keeps retries working aftercache.jsonwas 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-quitIPC →app.quit()), since the user's next step is Steam Verify Integrity; also reached from the post-update nudge inApp.jsx. Browser cache cleared viasetBrowserCache({}, {})+refreshKey. Help:browser_help_restore_all,BUTTONS.restoreAll. i18n:browser_restore_all*,restore_all_*(zh+en).Collapsible airport cards (level browser): each
.airport-cardheader 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 —stopPropagationso they never collapse the card). A one-shot auto-collapse fit pass (useLayoutEffectinBrowserScreen.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 bybrowserAutoCollapseDoneinappStore— 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;setRootPathclears all three flags. i18n:browser_airport_expand/browser_airport_collapse(zh+en).Post-update nudge:
electron/updater.jswritespost_update_pending.jsoninuserDatainsideinstallUpdate(beforeapp.quit(); dry-run gated byAC27_UPDATE_MARK_PENDING=1);electron/main.jsexposescheck-post-update-pending/clear-post-update-pending(electron/preload.js);src/App.jsxafter boot (bootingfalse, notprompt/downloading/installing, no modal open) checks once viacheckPostUpdatePendingand shows one-time modalupdate_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:
EditorScreentoolbar button (IoPencil, i18ntoolbar_ground_painter) → storetoggleGroundPainter→showGroundPainterportal (src/components/EditorScreen/GroundPainter/—GroundPainter.jsx+.css,GroundPainterToolbar.jsx,snap.js,fillet.js,metrics.js). Read pathsrc/acl/scenery_graph.js:buildSceneryGraphruns in the main process (load-ground-painter-dataIPC, because the renderer can't reliably dynamic-import the CJS acl module); write pathsrc/acl/scenery_write.js:patchSceneryBlobviasave-ground-painter-dataIPC (.bak+writeAcl, returns the new baseline). Edits are in-memory only until Save (confirm prompt,.bakcheckbox 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_flightsstill run); depth-1Ctrl+Zvia pairedgroundPainterHistory/groundPainterMetaHistory(appStore.js:setGroundPainterGraphWithMeta). Snapshot is onlycurrentPath(a per-level subset — see the v5 note), so no cache invalidation is needed. MCP provides full toolbar coverage inelectron/api-server.js(documented inac27-editor-mcp/SKILL.md§10 — all tools push paired history, deduplicate nodes, and recordmeta.deletedPks/deletedAreaIdsso 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.runwayPavementcoupling),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/09or27L) are edited only via floating on-canvas text boxes (runwayOverlayinGroundPainter.jsx) — not the toolbar — each a plain 3-chartype="text"input (maxLength={3}) with no in-place validation, so a box can be cleared to empty while typing; theDD/DD[A-Z]format (two digits, optionally one capital letter) is validated on Save (invalid names block the save and showground_painter_validation_runway_nameviasetGpError). The toolbar has no runway end-name editors. The globalkeydownhandler ignoresDelete/Backspace/Escape/Ctrl+Zwhenever focus is in a text/edit field (isTextEditTargetinGroundPainter.jsx), so typing or clearing a name box never deletes the selected runway. Runway-vs-pavement selection: in the Select tool,taxiway-segmentstrips whoseName=== a runway'sphysicalName(ZSJN runway01/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-memoryvalsstay stale until the level is re-entered). Full internals inreferences/acl-format.md"Ground Painter — id-free Scenery Graph".Live scenery (in-memory only, per-file):
Stand/Runway/Airwaydropdowns are never read fromcache.json— they come fromelectron/main.js:liveSceneryCache(Map keyed by absoluteaclPath,{icao, mtimeMs, vals}, mtime-checked via_isLiveCacheFresh). Helpers:_collectLiveSceneryForFile(reads the open.aclviareadAclText+_parseStandPositions/extractV4RunwayPairs+buildSceneryGraph/extractStarRunwayMappingsetc.),_getOrComputeLiveScenery,_unionLiveEntries(merge forcollect-valuesICAO-only callers),_buildCollectValuesBase(strips live keys fromairportCache). IPC:get-live-values/invalidate-live-values(preloadgetLiveValues/invalidateLiveValues);collect-valuesandscan-runway-pairsdelegate to the live union and return[]when no live entry (not stale disk data).init-airport-cache/refresh-root-scanand their_writeCachepersist blocks stripStand/Runway/Airway(andstandPositions);save-ground-painter-datadoes_invalidateLiveCache+_getOrComputeLiveSceneryafterwriteAcl. Renderer:EditorScreen.jsxmounts viagetLiveValues(filePath, icao)withcollectValuesfallback;GroundPainter.jsxpost-save refreshes viagetLiveValues. Stand rename of a newly-created stand (identifierfield) is honored byscenery_write.jsso the dropdown Identifier matches the typed name._buildCollectValuesBaseadditionally 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.binis deliberately NOT the source: an in-game stress test writing all 585 catalog callsigns proved it over-reports (KLM982/KLM983/KLM984crash) and is voice/bundle scoped (CCA9052only allocates for a Chinese-captain voice); the shipped union is every-callsign-proven. The Flight # picker (FlightTable.jsx) unions it with_flightNums[airline], whileAirlineCodeand its whitelist stay per-airport (a foreign airline fails callsign allocation in-game even with a recorded number) — seereferences/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 byREALTIME_IMPORT_ENABLED = falseinEditorScreen.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_depreturnfunction_access_restricted; they're date-only anyway), caps each request atlimit=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 (isoToLocalHHMMreads the clock substring directly). Wiring that remains in place: main-processelectron/aviationstack.js(plain-HTTPhttp.get— free plan rejects HTTPS — with in-band{error:{code}}mapping),aviationstack-fetch+validate-flightsIPC inelectron/main.js(the latter overlays livevalsonto the MCPbuildConstraints/validateFlightObjectspath),aviationstackKeyinac27-config.json(get-configwhitelist, same semantics as the cloud-LLM vendor keys), and the pure mappersrc/utils/realtime/aviationstack.js(buildFlightsFromAviationstack: airline/aircraft-type/registration intersection against livevals, 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 → fullAircraftType) in_buildCollectValuesBaseforaircraft.icaomapping. i18nrealtime_*(zh+en). Tests never hit the network:tests/electron/aviationstack.test.jsstubshttp,tests/unit/realtime-aviationstack.test.jsis pure, andtests/unit/realtime-kjfk-fixture.test.jsreplays the savedtests/fixtures/aviationstack-kjfk.json.Live weather import (aviationweather.gov, toolbar right of AI):
TiWeatherPartlySunnybutton (live_weather_title实时天气/Live Weather, tooltiptooltip_liveWeather) wired via theuseLiveWeatherImporthook — 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-processelectron/live-metar.js(the API has no CORS, so the renderer cannot fetch directly; custom User-Agent, 5-min cache) +fetch-live-metarIPC inelectron/main.js(electron/preload.js:fetchLiveMetar); pure mappersrc/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 viaAIRPORT_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). i18ntl_live_*toasts +live_weather_title(zh+en). Tests:tests/unit/realtime-metar.test.js(pure, captured fixtures),tests/electron/live-metar.test.js(mockedhttps),tests/integration/live-metar-api.test.js(real endpoint, gated onAC27_LIVE_API=1, skipped otherwise).Ground Painter area-type toolbar toggle (no dropdown): when the Area/polygon tool is active,
GroundPainterToolbar.jsxshows a 3-button toggle popover (.gp-areatype-group) that pops up above the highlighted polygon button (.gp-area-wrapwrapper). 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 defaultareaTypeinGroundPainter.jsxis 2 (Building) (useState(2)). Clicking a toggle callsonAreaType(value); the popover renders only whiletool === 'area'.Ground Painter stand icon & select priority: Stand plane icon is fixed on the map (world size
baseDiag*0.028/2.4≈0.522 GUfor ZSJN — the size it had at 240% zoom), not screen-constant (getDynamicSelectThresholds,GroundPainter.jsx:30-43; render/hit-test usefixedWorldSize/512andfixedWorldSize/2at:2948/:3240). Select hit-test is dynamic:getDynamicSelectThresholds(viewBox,baseVB)computesscale=vbDiag/baseDiag(<1 when zoomed in) andTH=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 usestightStand— interior area wins ifstandDist>tightStand, otherwise precise stand wins. **Stand does not snap** (GroundPainter.jsx:1122and the placement/drag paths):TOOL_STANDpreview (onMoveearlysetWorld(raw)), placement (onClicknose=raw) and body drag (applyDragdx=raw-startWorldearly return) all userawcursor, notfindSnap, so moving a stand is free.Ground Painter box-select (multi-select) + select icon swap + right-click cancel:
GroundPainterToolbar.jsxselect button usesFaArrowPointer(react-icons/fa6,ground_painter_tool_select) and the Box Select button usesPiSelectionDuotone(多选,ground_painter_tool_select_all/ground_painter_tool_box_select) button immediately to its right (tool==='boxSelect',TOOL_BOX_SELECT='boxSelect'inGroundPainter.jsx). Box-select drags a world-space rectangle (translucent#6aa0ff6 4dashedrectwithHL_SWstroke,boxRect={x0,z0,x1,z1},boxDragRef,onMovebox priority,onMouseDownbox start vs group-move,onMouseUp→computeBoxSelection/computeAirBoxSelection→multiSelected=[]— helperpointInBox/computeBoxSelectionnow counts any vertex inside (segment/runway/area:somenotevery; 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 usescomputeAirBoxSelection— onlyairwayNodehits (pointInBoxon fix; procedures hoverable/selectable via click/shift but box marquee stays node-only);applyBoxClickis airway-aware and populatesmultiSelectedwhile syncingselectedAirwayNode(Select tool keeps its ownselectedAirwayNodestate).multiSelected:[{kind,idx}]lives beside singleselected(useState([])+boxRectstate),SelOutlinerendersselected+multiSelectedtogether, stand/airway iconisSelchecksisMultiSelected,deleteSelectedaggregatessegIdxs/rwIdxs/standIdxs/areaIdxs/airNodeIdxsdescending withallDelNodesGC viadoOrphanGC+ air cascade (degenerate<=1procedure drop + node reindex). Group body drag (dragRefkind==='multi', freeraw-startWorldtranslate oforigNodes+origAirwayNodesset) and node-grab within multi are handled; middle-button still pans. Node/endpoint grab precedes the body-drag fallback — inonMouseDownthe grab loop runs even whenonMultiis true, becausepointOnMultiSelectedtreats any point on a runway/segment line (including both endpoints) as "on selection"; with the oldif (!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 adraggingstate that adds.gp-overlays--draggingto the floating-overlay container, whosepointer-events: none !importantrule 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 hasuser-select: none(GroundPainter.css) so a drag never highlights the length/angle labels. Right-click (button===2) cancels all selection —onMouseDownearlybutton===2preventDefault()clearsselected/multiSelected/boxRect/dragRef/boxDragRef,svghasonContextMenu={e=>e.preventDefault()}to suppress the browser menu.Escapealso clearsboxRect/multiSelectedbeforeselected. ToolbaronDeselect/onDeletedisablednow checksselected || multiSelected.length. i18nground_painter_tool_select_all=多选/Box Select.Ground Painter taxiway → select single-click cancel: In
taxiwayLine/taxiwayCurve(fillet) with an in-progress draft (committingone point orfilletPicks1/2), a single click on either select button (FaArrowPointeronToggleSelect) or multi-select (PiSelectionDuotoneonTool('boxSelect')) immediately cancels the edit and switches mode —onToggleSelectcheckstool!==TOOL_SELECTthencancelPlacementInput(); setTool(TOOL_SELECT); setSelectEnabled(true),onTool('boxSelect')already didcancelPlacementInput()viaGroundPainterToolbaronTool={(id)=>{cancelPlacementInput(); setTool(id)}}; thelastToolRefeffectif(lastToolRef.current!==tool) cancelPlacementInput()is the backup.cancelPlacementInputclearsfilletPicks/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).computeFilletreturnsground_painter_fillet_error_parallelfor parallel/collinear-disjoint picks, andcommitFilletearly-returns on!res.ok. Preview draws dashedtangent→nearconnection legs for virtual picks instead of the truncatedfar→Tlegs. Tests:tests/components/EditorScreen/GroundPainter/fillet-virtual.test.js(+fillet-connected.test.jsfor the connected/truncation path). The component-levelcommitFilletfor a T junction (deg>2) keeps the O-T stubs for the third arm;parentOsmA/parentOsmBare hoisted out of the connected-legelseblock so the stub bookkeeping can read them (aconstdeclared in the sibling block threwReferenceError: parentOsmA is not defined). Regression:tests/components/EditorScreen/GroundPainter/GroundPainter.test.jsx(T junction commits without error and keepsmeta.segOrigPkaligned).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.deletedPksis a ghost — deliberately kept ingraph.nodesso every other entity's index stays stable, but dropped from the.aclbypatchSceneryBlob. A new entity (one the writer re-synthesizes, i.e.meta.segOrigPk/standOrigPk/runwayOrigPkisnull) 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.jsexportsghostNodeIndices(graph, meta)(the ghost index set) andrepairGhostRefs(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.commitFilletcalls it right after ghost-deleting the O nodes (at a T junction the arms often use co-located twins), andsave()calls it again as a guard — a repair there is logged withconsole.errorbecause it means an earlier edit left the graph inconsistent. Returns{remapped, dropped, warnings}wherewarningsare i18n keys. The writer is the second half of the defence:_synthesizeSegment/_synthesizeStandreturnnull(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 bytests/integration/ghost_ref_invariant.test.js.Ground Painter — survivor dangling-
$irefgate + last-resort validation: deleting a taxiway node in the painter drops it from the.acl, but survivor entries are copied verbatim — so a survivortaxiway-segment/standstill holding a$irefto it serialized an unresolvable reference that the game'sTaxiwaySegment2DFactorynull-derefs on level init (the ZSJN_leisure_1 crash:$iref:2004/$iref:2040).patchSceneryBlobnow runs_gateSurvivorDanglingRefsbefore the jetway cascade, repairing each survivor in order: (1) rewire the dead ref to a livetaxiway-nodeat the same coordinate (co-located junction twins are geometrically identical); (2) excise the ref from the entry's$rcontentlist when ≥2 refs remain —_exciseIrefFromRcontentalso decrements that list's$rlength(the last"$rlength"before the"$rcontent"key), and returnsnullfor a ref that is a bare property (a stand'sNosePosition/TailPosition), which falls through to the drop path; (3) drop the whole entry — its ids joindeletedIdsfirst, 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 danglingtaxiway-segment/standentry 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 bytests/integration/survivor_ref_gate.test.js(reads a real game level viatests/helpers/gameRoot.cjs(ESM facadegameRoot.js) — skips cleanly when the game is not installed atAC27_GAME_ROOT). Triage scripts for this class:tests/integration/scan_dangling_refs.cjs(every dangling$irefgrouped by owning PK and section,--diff a.acl b.acl),scan_taxiway_health.cjs(walkstaxiway-segmentNodeslists the wayTaxiwaySegment2DFactorydoes — flags dangling ids,$iref:null, non-node targets, positionless nodes) andscan_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 optionalopts.warningsarray; thesave-ground-painter-dataIPC handler passes one and returns it alongsidenewText/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, sokey+paramsdrive renderer-side i18n (writerWarningTextinGroundPainter.jsx;textis the plain-English fallback for console logs and any key the dictionary does not cover). A save that succeeds but dropped geometry now shows aground_painter_saved_with_warningsmodal listing the warnings (and still installs the new baseline) instead of silently losing it. 17 new i18n keys (zh + en insrc/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 eightground_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.jsximports any raster image as a map-anchored reference layer behind the scenery (bgImagestate{src,naturalW,naturalH,anchorX,anchorZ,baseW,baseH,scale,offsetX,offsetY,opacity},setBgImage/updateBgImage). Image is auto-fit to the currentbaseVB||viewBox(fitScale = min(fw/naturalW, fh/naturalH), anchor = viewBox center) withscale:1, offsetX:0, offsetY:0, opacity:0.6on import (handleImportImageFileReader→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 inGroundPainterToolbar.jsxgp-bg-panel(toggled byIoImageOutlinebgPanelOpen): import/remove/reset (clearBgImagenulled,resetBgImage→ scale 1/offset 0/opacity 0.6) + four sliders — L/R (offsetX -100..100%), U/D (offsetY -100..100%), Scale (10..500%, storedscale 0.1..5), Opacity (0..100%, storedopacity 0..1, default 60%,Math.round(opacity*100)%readout,onBgOpacity(v/100)). Panel isposition:absolute bottom:100%+8px right:12pxwith head/close/import/row/ctrl styles inGroundPainter.css(.gp-bg-panel,.gp-bg-ctrl). The placed image persists as a<file>.acl.bg.jsonsidecar written by thesave-ground-painter-dataIPC handler and is restored on reopen against the frozen_groundAnchor(electron/main.js). i18nground_painter_bg_*(toggle/title/import/remove/offset_x/offset_y/scale/opacity/resetzh:背景图/导入背景图/移除/左右/上下/缩放/透明度/重置, 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 mirrorsrc/components/EditorScreen/GroundPainter/polygon_simple.jsfor the renderer, which cannot import the CJS acl module) rejects bowties:_segProperCrosswith bbox pre-filter, adjacent-edge sharing exempt, degenerate edges ignored.GroundPainter.jsxfreezes a vertex drag at its last valid position when!polygonIsSimple(newPoints);electron/api-server.js:create_areasvalidates 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:applyDragnode mode scans every polyline containing the dragged node and freezes the drag if any consecutive pair would colocate; (2)fillet.js:attachVirtualFilletLegdedups the tangent polyline and drops a leg that would be <2 distinct positions,applyVirtualFilletpre-checks both legs anchoring at the same position (or same shared-position node) and rejects before mutating, andrepairGhostRefsfinal sweep drops degenerate segments (self-loop or consecutive dup); (3)scenery_write.js:patchSceneryBlobreferenced-node rescue keeps anytaxiway-nodestill$iref'd by a survivingrunway/taxiway-segment/stand/taxi-navigation(3-pass fixpoint), then_gateSurvivorDanglingRefsnow coversrunwaythresholds too (unrepairable threshold drops the runway + its pavement strips viadroppedRunwayPhys), and thePhysicalRunwayregistry + pavement-strip suppression removes orphan paint after a gate-dropped runway. (4) The delete-time orphan GC dedups its candidate indices (Set, bothapi-server.jsandGroundPainter.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:deleteSelectednow cascades throughcascadeDeleteRunway(recordsmeta.deletedPksfor strips +deletedRunwayNamesfor end/physical names, GCs strip + threshold nodes) andrecordDeletedStand(recordsdeletedStandNames), both for single and multi-select (box-select).electron/api-server.js:delete_ground_objectsmirrors the cascade with orphan GC anddeleted*Namesbookkeeping;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_runwaysnow suffix-deduplicates heading-derived designations (L/RthenC) against takenphysicalName/end names so two runways never synthesize duplicate$k: runway:<name>keys ("found 0 named runways"). Flight reconciliation inelectron/main.js:save-ground-painter-dataafterwriteAcl: 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), buildsrunwayRename/standRenamemaps (index-parallelrunwayOrigInfo/runwayOrigPk+ nose-position matching for stands, guarding that rename targets still exist in the saved file), remaps flights, purges any flight whoseStand/Runwayexisted pre-save but no longer resolves, rebuilds flight sections viagenerateFullAcl(with approach cache +exportGameCSVsync), and returnspurgedFlights/refsRemappedalongsidenewText(when flight sections were rebuilt,finalText = readAclText(filePath)so the renderer's snapshot reflects the purge). Box-select/group-move thresholds usemove_ground_objects. Runway rename MUST cascade tograph.procedures[].runwayName(and to the directionalentries/exits):scenery_write.js:_buildRoutesWrapperForPatchrebuilds each runway'sRoutes[]fromgraph.proceduresgrouped byrunwayName === <runway PK suffix>, so a rename that updated onlyrunways[].names/physicalName/strips left every procedure'srunwayNamestale → they matched no runway and the wholeRouteslist 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.SpawnFlyApproachingAircraftNullReferenceException at level load). Both rename paths cascade:GroundPainter.jsx:updateSelectedRunwayNamesandelectron/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 whoseAirway(STAR) existed pre-save but is gone from the saved file (reasonSTAR procedure removed or renamed away;purgedFlightsentries carrySTAR), 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 resolvesFlightPlan.STARamong the routes of the flight's OWN runway, so a STAR that survives on another runway is not enough (KJFK_runwaychange: the air fuzz renamedSIE.CAMRM5@4L, leavingSIE.CAMRM5@4R/@15L; 9 flights on4Lwere purged with reasonSTAR 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 intests/electron/api-server-air-functional.test.js.Ground Painter — runway synthesis on shipped v5 levels (per-scope type sampling + optional
IsActive):_sampleRunwayShapesresolves any still-null sub-object$typefrom the PK-blobdoc-scoped type table (_documentTypesByName;patchSceneryBlobslicesranges.bdand 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.29isList<IStaticEntity>in the level blobdoc butReactiveProperty<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;_assertSampledTypestill guards the required geometry types (the fixture'sno fallback allowedassert stays green because that file genuinely lacksPhysicalRunwayStaticItem). Fixes the ground-fuzzcannot determine type "ReactiveProperty<bool>" from the runwaysave abort. Regression:tests/integration/runway_entry_type_id.test.js(game-root-gated v5 case).Ground Painter — renamed/dropped runway pavement-strip cascade: when
_gateSurvivorDanglingRefsdrops a renamed survivor runway (its thresholds went dangling), it records the entry's pre-renamePhysicalName, 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-renamephysical-runway:*keys, and expandsdroppedRunwayPhysalong the old→new mapping (worklist handles A→B→C chains) so the name-based strip suppression (pkOutSynth.filter(...)againstdroppedRunwayPhys) drops both names. Without it the save emitssaved 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-errorrefusal (was a facelessundefinedtarget) and reports the offending runways + strip details.Ground Painter — orphan-GC index dedup (self-loop refusal): deleting a runway feeds
doOrphanGCits 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:doOrphanGCfor MCP,GroundPainter.jsx:doOrphanGCfor the UI) must collect orphans in aSetbefore 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 (_validateNoDegenerateEdgesrefuses:taxiway-segment … joins vertex '…' to itself, no file written). Defence in depth:scenery_write.js:_synthesizeNewdrops any NEW segment whose consecutive resolved node ids repeat (same policy as the null-ref drop,ground_painter_writer_new_segment_droppedwarning). 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 itstaxi-navigation:stand/:pushbackpoints can survive — a pushback point references a LIVE taxiway node, so the dead-$irefcascade skips it, yet itsRelatedStandthen 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 ataxi-navigationentry whose non-emptyRelatedStandis absent from the live stand identifiers (built from eachgraph.stands[].identifier/.name), while keeping the singleCrossTaxiwayNamesdeclarer (rewired by the gate) and entries with an emptyRelatedStand(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
RunwayTimelinestill activates; Unity resolvesInitialRunwaysat load and throws NullReferenceException on a name with norunway:entry (ZSJN_leisure_2ground fuzz leftInitialRunways: ["19"]after the01/19runway was gone), and the AC27 playtest throwsArgumentException: runway.initialRunways references runway '01' that does not exist in the loaded airport. Parameter name: timeline(ZSJN_runwaychangeground-editor "delete everything").patchSceneryBlobcalls_pruneRunwayTimelineReferences(text, liveRunwayEnds)— withliveRunwayEnds = _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 fromInitialRunways(_pruneInitialRunwaysText) and prunes theTimelinechange frames (_pruneRunwayTimelineFrames: drop aRunwayChangewhoseSource/Destno longer resolve, drop aRunwayChangeFramewhoseChangesempties, recompute every$rlengthvia_setArrayRcontent). When some live ends remain but the list would empty, it seeds the first live end; when the painter removed every runway, it leavesInitialRunways/Timelineempty instead of fabricating one. Conversely_ensureInitialRunwaysContain(text, _newRunwayEndsFromMeta(graph, mm))auto-adds every painter-CREATED runway's primary end (meta.runwayOrigPk[i] == null) toInitialRunways, so a newly-drawn runway is active. Zero-runway saves are refused: the Ground Paintersave()showsground_painter_validation_runway_requiredbefore the IPC call (re-checked afterrepairGhostRefs, so a runway the repair dropped is caught in the renderer) and refuses same-named runway ends, and thesave-ground-painter-datahandler counts"$k": "runway:…"keys in the patched text and throws with diagnostics (canvas runway count/names, written count, writer warnings) — the game throwsArgumentException: runway.initialRunways is emptyon a level with no runway._remapRunwayNameFieldsalso rewrites runway-change frameSource/Dest(those fields carry noRunwaysubstring), so a renamed runway's timeline follows. Regression:tests/integration/runway_timeline_prune.test.js(+ auto-add assertion intests/integration/scenery_physical_runway_cleanup.test.js).NEW-Area write safety (do not regress):
scenery_write.js:_sampleAreaShapesmust sample the innerList<Vector3>type with a regex that tolerates the$idwrapper 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 toArchiveHeaderand rejects asInvalidOperationException: Invalid Area static entity(+Entry of type "StartOfArray" in node "" is missing a name). The regex + null-safe fallback type strings are inscenery_write.js; deep detail + the_synthesizeAreashape are inreferences/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 with33|Dictionary<string, IStaticItem>→Type id 33 claimed by both ...when re-adding an area after deleting them all; regressiontests/integration/area_readd_after_delete_all.test.js). Bare-$typeread-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
$typesafety + runway save gates (hardening): every synthesized-entity$typefallback resolves against the owning$blobdocscope's type table first, else mints a fresh id above that scope's max carrying the canonical type NAME (_resolveTypeOrFreshfor areas/stands/runway sub-objects,_resolveRuntimeScopeTypefor checkpoint-frame runtime entities; one fresh id per name per patch viafresh.byName;_scopeMaxTypeIdtolerates whitespace before}). Why: type ids restart per$blobdoc, so hardcoded ids collided —33|List<Vector3>vs33|Dictionary<string, IStaticItem>when re-adding an Area after deleting them all,20|Standvs20|Runway+Route, and the runtime3|PhysicalRunwayvs the RuntimeEntities scope's3|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 whoseEntries/Exitswere emptied (the element type is then neither sampleable nor in the table). Companion gates: (a)_gateSurvivorDanglingRefsprunes per-element a survivor runway'sEntries/Exitswhose serving-taxway nodes were deleted (via_pruneRunwayEntryExitElements,$rlengthrecomputed,ground_painter_writer_gate_runway_entries_prunedwarning) 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 structuredground_painter_writer_new_runway_droppedwarning rather than vanishing; (c) rendererGroundPainter.save()re-checks the runway count afterrepairGhostRefsand refuses same-named runway ends (ground_painter_validation_runway_same_name) before IPC; (d)save-ground-painter-datadiagnostics 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_resolveTypeOrFreshif such a level shows up.Ground Painter — runway entrance/exit access + taxiway visual-path continuity + PK type regroup:
buildSceneryGraphnow aggregates each physical runway's directionalEntries/Exitstables (runway:01/runway:19blocks) into the collapsed graph runway (rw.entries/rw.exits— each holdsname,runwayName,holdingIdx,lineUpIdx/exitIdx,defineIdx,isLeft, plus raw$irefs) and snapshots them intometa.runwayEntriesOrigfor 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 withmeta.runwayPavementfallback, 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 (toggleRunwayAccesswrites the entry/exit node indices + patches nodeType/Flags; on runway rename it keepsentries/exitsdirection-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$blobdoctable, 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.jsosmFromSegPk) now carriesparentOsmonto 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 — fixesTaxiway visual path 'N' is discontinuous) and_synthesizeSegment/_synthesizeNewcopy 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, elseInvalidOperationException: Taxiway segments '...:0' and '...:5' for OSM way '...' have inconsistent visual properties(KDCA_leisure_2-379142, ground fuzz; a hardcodedIsHidden/IsUnselectable = falseon the synthesized piece was the cause;Nameis part of the signature too — KDCA_leisure_2-378884, air-map fuzz, renaming ONE piece of a 22-segment way left:2named"T42"while the other 21 were unnamed). To hold the invariant the writer propagates an edited survivorNameacross its whole OSM way (segNamePatchexpansion), 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);_splitGroupIntoLinearChainsdecomposes 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 bytests/integration/taxiway_path_continuity.test.js); a genuinely-new taxiway still gets a fresh OsmId.patchSceneryBlobnow regroupsPKStaticEntitiesinto the file's canonical type order (_pkTypeOrderfrom first-appearance +_regroupPkByType, stable within a group) so a newly-drawn taxiway-node joins its type block instead of the array tail. Detail inreferences/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.jsxmodebutton →onToggleMode;mode==='air'setsisAirModeinGroundPainter.jsx,setGroundPainterModeon 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};routeType0 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;commitProcedurevalidates name+runway, checks duplicates, pushes paired history), Air Fillet (f— strictly intra-procedure AAA:findNearestAirwayEdgeedge-based, two consecutive edges sharing the interior FIX within the SAME procedure; after first pick highlight/snap locked to that procedure's edges;computeFilleton two 2-point edge segments,airFilletPreview+#ffd34d/#8a94ffhighlights, radius 50..500 GU step 5 viaFILLET_LIMITS(ground 0.5..5.0 step 0.05;useEffectresets to 120/2.0 on mode switch);commitAirFilletintra-path replacesOwithT1+arcInterior+T2nameless nodes and reroutesairwayNodeIdxs; inter-procedure edge picks fall back to legacy truncation +ARC####bridge; new i18nground_painter_air_fillet_error_same_procedure), plus the shared Select (a)/Box Select (s). **BothairwayNodeandprocedureare selectable via single-click / shift-click / hover:pickForegroundair path checksfindNearestAirwayNodethenfindNearestProcedure(hover + click share priority, filtered byactiveRunways/activeProcTypesplusbgImagefallback),HoverOutlinehighlightsairwayNodedouble-circle orproceduredashed polyline (#ffd34d8 5),SelOutlineis thicker0.34*strokeScalewhenisSel(selectedProcedureIdx===origIdxorisMultiSelected('procedure',origIdx)). Drag: selected node →mode:'node'on the fix; selected procedure → body drag withinairBody=max(0.30, airGrab*1.15)viaorigAirwayNodestranslating all its fixes, or node-grab on any member fix; pan if selection exists but cursor not on it. Delete:hasAirSelchecksselectedAirwayNodeORselectedProcedureIdx; singleselectedProcedureIdxsplicesprocedures+airwaySegOrigPkand recordsdeletedAirwayPks; multiairProcIdxs(click/shift only — box marquee stays node-only) are deleted directly before the degenerate-<=1cascade and node reindex. FilletcommitAirFillethead/tail invariant:T1/T2mapped to procedure direction (prevNodeIdx→tPrevIdx/tNextIdx,orderedInteriorreversed when needed) so B→A equals A→B.renameAirwayNodetick now closes box (setSelectedAirwayNode(null), matchingrenameProcedure).isTextEditTargetrobust (tag.toUpperCase(),!!isContentEditable, comment notes forward-delete) and runway/stand/segment/airway floating inputs addonKeyDown e.stopPropagation(). Air translation-only —rotateSelection/multiRotate/[ ]shortcuts early-return whenisAirModeRef.current,buildRotationPlanstill collectsairwayNodesfor ground butmultiRotatePlanis null in air, group drag carriesorigAirwayNodesand 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; baseDOT_R*1.0, selectedDOT_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. MCPcreate_airway_filletmirrors the intra rule (procedure+vertex1..len-2or legacyprocA==procBauto-pick sharpest corner, radius 50..500;procA!=procBrejected). i18nground_painter_node_name(zh导航点名称/ enAirway 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.jsxrenders a floating box anchored to the selected point (airwayNodeRenameOverlay/procedureRenameOverlay) positioned in screen space viasvg.createSVGPoint().matrixTransform(ctm)with viewBox fallback, clamped to the SVG rect (210×36, 18px offset, 8px margin). The box (#0a1628+#ffd34dborder,boxShadow) contains a textinputbound torenameAirwayNodeName/renameProcedureName(pre-filled viauseEffecton selection), a Cancel[×](setSelected*null) and Confirm[√](rename*()→ clone graph + set name + paired history).Entercommits;onMouseDown/onClickstopPropagation()so the map doesn't deselect. The old bottom.gp-proc-bar gp-proc-renamebars are removed. The sameisTextEditTargetguard (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
isAirModethe 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-gridblock). Vertical/horizontal lines fromfloor/ceil(viewBox/step)*stepat0.9/pxScale(#1e3a5a, 0.38), axes0at1.6/pxScale(#2f5a8a), origin marker (4.2/1.6 px) +0,0label (#6aa0ff), and per-10-NM axis labels (maxLabels 20,#3a5a8a). Grid ispointerEvents:noneand 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: HoverOutlinerenders a dashed#ffd34dpreview of what a click would select when(tool===TOOL_SELECT && selectEnabled) || tool===TOOL_BOX_SELECT(hoveredstate{kind,idx}set inonMove;isHoverToolguard,useEffectclears when leaving select modes or togglingisAirMode). Priority mirrors the Select tool: standtightStandvs segment vs area (boundarycost 100interior-aware) vs bgImage; air:airwayNodethenprocedure(findNearestAirwayNodeelsefindNearestProcedurewith active filters, elsebgImage). Hover is hidden while dragging (onMouseDownclearshovered), cleared ononMouseLeave, 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: segmentpolyline+ dots, runwaypolygon, areapolygon, standrect+line, airway node doublecircle(1.45/0.95×), procedurepolyline(hSw*1.058 5), bgImagerect. Never interferes withSelOutline(solid highlight).Ground Painter — select snap in air mode + procedure init snap:
onMoveinisAirMode && tool===TOOL_SELECT && selectEnabledcallsfindAirSnap(raw)and setsworldto the snapped endpoint (SNAP_TYPES.ENDPOINT) for a visibleSnapIndicator(isAirMode && world.type===ENDPOINT).TOOL_AIR_PROCEDUREnow snaps on the init node (first pick) as well (if(tool===TOOL_AIR_PROCEDURE)without thecommittingProcedure.length>0gate).handleMouseDownhideshoveredon drag;onMouseUpcleared.Ground Painter — right-click / areaType / loading / mode-reset:
onMouseDownbutton===2(a) backtracks the air procedure draft (committingProcedure length>1 ? slice(0,-1) : null, cleargpError) whenisAirMode && 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) forareaType===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.jsxshows a loading overlay (isInitialLoading/modeTransition→gp-loading-overlay+gp-spinner+gp-loading-text,aria-busy,.gp-emptykept hidden for legacy tests) with i18nground_painter_loading/ground_painter_loading_air/ground_painter_loading_ground(src/utils/i18n.jszh正在加载…/正在加载空中视图…/正在加载地面视图…, enLoading…/Loading air view…/Loading ground view…;GroundPainter.css.gp-loading-overlay,+--initial,.gp-spinner,@keyframes gp-spin).src/store/appStore.js: toggleGroundPainteralways starts in ground mode (groundPainterMode:'ground', groundPainterTool:'select', groundPainterActiveRunways:nullwhen opening) — reset persisted painter state on every enter.GroundPainterToolbar.jsxair/ground toggle keepsposition:relativefor 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 surfacesudpConnected, useCrossWindowSelection, useWitchAnimation, useKnobPositions), ControlSidebar with SpinKnobs, witch mode, cross-window selection sync, drag reorder, and the sharedLiveSessionOverlay(blurred "no live session" notice driven byudpConnected === 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 coverselectron/cloud-llm.js(DeepSeek/Gemini/Claude/Codex chat with tool calling) and theChatPanelReact component.dev-commands.md— All npm/node commands: component tests, E2E tests, integration tests (with--acland--rootvariants), 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 fromtargetPlaneId) plus alist-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 128pxPANEL_GAPgutter; 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/readLiveryImagesloads andexportParts/create-livery({images})saves every panel,baseFileNamemaps part→file) / opaque base primed from the per-aircraft built-in UV template viaget-aircraft-template(electron/dds.jsDXT1/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-axisstretchinstead 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 purereorderObjects, 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 stackeddata-layerlayers 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, black2/zunder
white1/z) chained by puretraceMaskBorder/chainBorderSegments; on a multi-panel type a lasso/wand selection is confined to the ACTIVE panel
(clipActivePanel+wandRegionregion bounds) so it can never cross the gutter, and anactiveRefmirror makes a
single press into another panel run the wand/fill/lasso there (theactivestate update is async); each movable carries a
persistedpanelchosen by the POINTER while it is dragged (paintObjectInPanel/objectPanelclip 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 visibleclipMask-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 +erasePolysholes 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 inappStoreasliveryScrollTop/liveryCollapsedGroups/liverySearch, cleared bysetRootPath, 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+ painterpreview3dchip, with the mod-enable warning as a TOP highlighted tip (#livery-help-tip,margin-bottom:18px) on both pages), pack-rootmod_info.jsonauto-write/repair on load+create, Save As overwrite confirm + post-save mod-enable hint (cache.jsonflags, logic in pureelectron/cache-flags.js), renderer-canvas image rules, share/export ZIP contract; 3D preview (no Python required) —LiveryScreenextracts 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-JSelectron/unity/reader of the user's ownresources.assets,scripts/extract-aircraft-models.pyUnityPy is last-resort fallback only,threedependency, packversion 2cached under<userData>/livery-3d-models/and deleted on page leave; painterLuRotate3Dbutton right of the aircraft select hidden for types with no model viahas3DModel(Cessna Citation X), floatingLivery3DPreview.jsxwindow with live ~1 sgetRevision/getPanelCanvases()full-res canvas-texture updates (no PNG round-trip); list cards render 3D-only snapshots viasrc/utils/livery3d.js— the resized PNG thumbnail channel is NOT used); multi-part types (A388/B38M) — a livery'sparts[].partNamemust match the built-in default's main part (Fuselage, notBody) or the game ignores the texture; image read/preview resolves the manifest's main-part BaseMap (base_Fuselage.png, any-part then legacybase.pngfallback, PNG/JPEG) andexport-liveryships 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-effortworkshopContentDirwalk-up +listWorkshopLiveries, read-only Steam badge cards,pack:'workshop'reads); Workshop publish — the list header Upload button (singlemineselection) and the painter Steam button openUploadLiveryDialog(get-workshop-publish-info/publish-livery/open-workshop-log/workshop-debug-info,workshop-upload-progress) backed byelectron/steam-workshop.js(constant appid3328490, the shipping game,.workshop.jsonsidecar identity, verbatim content pack, preview save/reuse + 1 MiB shrink, create-or-update — a.workshop.jsonsidecar 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.jsspawnssteam-workshop-worker.js(pure logic insteam-workshop-core.js, shipped viabuild.jsextraResources) 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; seereferences/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 mainLevelPayloadOdin segment +NODH, frames with an embeddedNODFcommit 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 bysrc/acl/id_renumber.js(renumberAclIds):$iddeclarations and$irefreferences are rewritten to a strictly ascending sequence in text order using one shared counter per document, with$blobdocvalues renumbered as independent documents (fresh 1..N) sharing a segment-wide old→new registry (so outer-documentaircraft:stub entries can$irefids 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$irefhandling: a$irefwhose target is not yet declared is preserved verbatim and its value reserved (never re-allocated to a fresh$id) — the game reads a dangling$irefas null. The tradeoff is a same-scope forward$irefin a file with id gaps can silently misbind. Related: whenwriteAclreceivesoptions.originalText,renumberAclIdsrepairs$typere-registration — ids recoverable from the original text's declarations are re-introduced as inlineN|Nameforms at the first surviving bare ref (armed only on the Ground Painter path:scenery_write.js/electron/main.jspassoriginalText; the flight-save path does not).$eref/$fstrref/$guidrefare untouched (different id spaces).countIdDescents(text)is the diagnostic (violations/dups/max per scope, blobdoc-aware — it DOES descend into nested$blobdocvalues; healthy game files score 0). Covered bytests/integration/id_renumber.test.js. v2 frames:writeAclcopies 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$fstrrefnulling,$irefremapping) persist for v2 as well; frames that do not decode to a checkpoint doc are copied verbatim. Before this fixframesRawwas re-copied verbatim and the edits were silently dropped — the game then abortedGameStateRegistry.RestoreWorldon stale runtime entities (the fuzz-produced broken leisure levels). Covered bytests/integration/gatcarc_v2_frame_edit.test.jsandtests/integration/v2_frame_reconciliation.test.js. - Flight plans: Flight-plan entries live in
StaticData.$blobdoc.StaticItems.$rcontentwithflight-plan:key prefix. UsesInitialArrival/InitialDeparturefield names. InBlockTime/TakeoffTime always 0 (game computes dynamically).AirlineNamestores the 3-letter airline code (e.g."CDG"), defaulted to the callsign prefix for new flights. The leg is chosen by the internalisDepartureflag (set at creation; the serializer falls back toOffBlockTimepresence —_isDepartureFlight()). - Type numbering:
$blobdocsections have independent type numbering from the outer document. Type N inside a$blobdoccan 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):
buildDesignatorMappingcross-referencesStaticItems(flight-plan → Registration, AircraftType, Stand) withRuntimeEntitiesin two passes to build theAircraftType → Designatormap. Pass A scansaircraft:REGentries forSpecification.Designator(linked via Registration). Pass B scansjetway:STANDentries forDockingAircraft.Specification.Designator(linked via Stand → AircraftType from the static-itemStandfield). This handles aircraft whose only runtime representation is inside a jetway'sDockingAircraftrather than as a standaloneaircraft:REGentry. Pass B resolves each jetway key throughjetwayKeyStandCandidates(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: whenStreamingAssets/aircraft_profiles.csvexists,loadGlobalSpecDB(airportDir)seeds bothspecDBanddesignatorMapglobally before the two-pass scan — the CSV is the single source of truth (parseAircraftProfilesCsvparsesAircraftType,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)(insrc/acl/utils.js, re-exported via the parser facade) derives the runway-change pair options for the RunwayEditor from the static runway objects inPKStaticEntities— each physical runway ("4L/22R") appears as two"$k": "runway:*"entries sharing aPhysicalName, yielding the reciprocal pair4L|22R+22R|4L.electron/main.jscallsextractV4RunwayPairson the first ACL in_collectAirportRunwayPairs(collectRunwayPairsremains only as a fallback if extraction throws). Note the flag-clearing rule inuseEditorSaveActions.jsx:timelineModified.runwaymust be cleared on save regardless ofrunwayTimelinePath(the ACL patch insave-aclalways runs; most real levels have an emptyConfig.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 → AircraftTypemap 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 withA/Bsuffixes; 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"); withincludeNumericParse=truetheparseInt-normalized form leads the list ("31A"→"31"; the alias forms still follow for the raw key). Consumers:buildDesignatorMappingPass 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 locatesaircraft_profiles.csvviafindAircraftProfilesCsv(walks up fromairportDir→StreamingAssets/aircraft_profiles.csvorGroundATC_Data/StreamingAssets/...), parses it withparseAircraftProfilesCsv, and cachesspecDB+designatorMapglobally (cloned per airport).extractSpecificationDBscanning ofSpecificationobjects inside jetwayDockingAircraft/RuntimeDataaircraft 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_1only RWY19, other levels only RWY01).buildApproachCache(airportDir)collectsallAclTexts[](every.acltext) and merges across all files:extractStarRunwayMappingsper file →mergedStarRunway/mergedRunwayStar(deduped by runway),state5ParamsMap/appPointMap/starPaths/starWaypoints/runwayThresholds/sidPaths/missedAppPaths/apprPaths/airwayNodesall merged (first hit or longest-path wins, runway deduped).allAclTextsfeeds 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 nestedPhysicalRunwayStaticItemobject which may be inline ({ $id, PhysicalName }) or a$irefto an inline object (sometimes double-indirected via a physical-runway PK alias)._parseRunwayThresholds,extractStarRunwayMappings,extractStarWaypoints, andsid_goaround.js:_extractRouteMappingsByTypenow resolve via_extractNestedObject(block,'PhysicalRunwayStaticItem')→_findPhysicalNameByIref(aclText,pkIndex,iref)(PK index + double-$irefwalk + raw-text"$id": Nscan fallback for inline ids like ZSJN 01$iref:8541) →PhysicalNameinside the physical-runway object, with a top-levelPhysicalNamefallback for pre-v5 files and arunwayNameplaceholder when still missing._findPhysicalNameByIrefis exported for reuse. - Area type 30→31 (v5):
scenery.js:_parseAreasnow accepts both30and31forContextCross.Models.Area— v5 shifted the type number from 30 to 31. Detection checks for the name substringContextCross.Models.Areafirst (version-agnostic), then falls back to numeric30|31for 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) parsesPKStaticEntities(taxiway-node/segment, runway, stand) +NonPKStaticEntities(Areas 30|31) into a Graph{nodes, segments, runways, areas, stands}where all refs are array indices intonodes(shared nodes deduped bycoordKey=x.toFixed(6),z.toFixed(6), so moving one node moves every incident segment/runway/stand). A parallelmetaside-table{nodeOrigPk, segOrigPk, runwayOrigPk, areaOrigId, standOrigPk, runwayPavement, runwayOrigInfo}carries each object's original$k/$idso survivors reuse their original id at write; new objects get a fresh id (max+1, new nodes/taxiways use fresh negative OsmId auto-generated viaminOsm-1— no finite pool, no UI counter/limit). Type=2 diff (final): newtaxiway-node→Type=2(PushbackLimitPosition,ENodeType) +Flags=0; newtaxiway-segment→Flags=2(Taxiway,EFlags, was1=StandInternalTaxiway) vialn.flags ??2(GroundPainter.jsx:1221,1503+ fillet1613/1614/1624/1637),seg.flags ??2(api-server.js:706),_synthesizeSegmentdefault2(scenery_write.js:511).patchSceneryBlobis lossless: surviving entity blocks are kept verbatim (so runwayRoutes/HoldingAreasand standTaxiwayNode[], 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 nodex/zvia_patchNodePosition(movedByPk), moved survivor area vertex/body coords via_patchAreaPoints(detected by diffing each survivor's graphpointsagainst its originalNodePositions.$rcontent; rebuilds$rcontentpreserving the ambient$type/wrapper$ids and updating$rlength) — so a building/apron you drag persists on save. All allocation is centralized at write;writeAcl→renumberAclIdsdoes the final ascending$id/$irefrenumber (never called insidepatchSceneryBlob— double-renumber would corrupt). Frozen geometry dims (taxiway half-width, runway width/edge/touchdown, stand length/pushback/holding rects) live as constants insrc/utils/constants/map-config.js— no other file may redefine them (scenery_graph.jspulls them viasrc/acl/constants.js; the painter UI importsmap-config.jsdirectly;scenery_write.jscurrently falls back to its own literals). The painter snaps onlycurrentPath(a per-level subset — runways/areas from other levels stay in their own files and are merged bybuildApproachCache), so a save does not bumpCACHE_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 inmeta— pushes the object's original key intometa.deletedPks(segment/runway/stand) or original id intometa.deletedAreaIds(area), and splices the parallelmetaarray so it stays index-aligned with the Graph; it commits graph+meta together with a depth-1 history for Ctrl+Z.patchSceneryBlobonly drops entries listed indeletedPks/deletedAreaIds, so without this the delete silently reverted (graph spliced,metauntouched → entry kept on save). (2) After a partial taxiway delete the survivingtaxiway-segment:<osm>:<ord>siblings keep a gap (e.g.1481:1..21after:0removed), which Unity rejects with "non-contiguous ordinal N; expected M";patchSceneryBlobrenumbers each per-osm group contiguous from0in the rebuild path. (3) Deleting a taxiway segment GCs orphantaxiway-nodes but preserves shared end nodes —GroundPainter.jsx:deleteSelected(src/components/EditorScreen/GroundPainter/GroundPainter.jsx:1246) collects the deleted segment'snodeIdxs(segNodeIdxs), checks each node against remainingsegments/runways/stands; only nodes with zero remaining incident references are spliced fromgraph.nodes/meta.nodeOrigPk(originalPKpushed tometa.deletedPksiforigPk != null; surviving indices >delIdxare decremented forsegments.nodeIdxs/aIdx/bIdx,runways.thAIdx/thBIdx,stands.noseIdx/tailIdx/pushbackIdxs, andmeta.runwayPavement). Curve interior nodes (exclusive) are GC'd, chain-shared endpoints and runway/stand-shared nodes are kept; the writer then drops exactly thosedeletedPksentries. (4) Deleting a stand also resolves every reference to it (_cascadeOrphanEntriesdrops the stand'sjetway:*static item fromStaticItems, and_reconcileJetwayFramesdrops the matchingjetway:*runtime entity from the checkpoint frame) — otherwise Unity throwsJetway: static item 'jetway:NN' does not exist in CurrentLevel.StaticField.StaticItemsand self-heals by deleting the stand + all its aircraft. Covered bytests/integration/scenery_delete_cascade.test.js. - Ground Painter snap engine — angle-based snap:
snap.jsfindSnap(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 beforeanchor): 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-modulatedangleToleranceDeg(base 2.5° viadynamicAngleTolDeg, clamped [0.6°, 12°] — the painter passespainterAngleTol(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+angleLabelmemo) 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 callfindSnapdirectly. Node-drag snap exclusion (applyDrag,d.mode==='node'): the snap geometry passed tofindSnapis filtered to drop the dragged node's own current coordinate and, when the drag carriesrunwayIdx/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 bytests/components/EditorScreen/GroundPainter/runwayEndpointDrag.test.jsx. - fmtFloat3 format change: Both
buildApproachAircraftBlockandbuildState5AircraftBlocknow emitfloat3values without named fields —v.x, v.y, v.zinstead of"x": v.x, "y": v.y, "z": v.z— matching the actual Odin serialization format. Five empty string-array fields in theRunwayCoordinatornow share a single$idvia$irefreferences instead of each getting a unique$id. - Stand heading from TailPosition→NosePosition: Stands link to taxiway nodes via
$irefreferences (TailPosition/NosePosition). The_parseStandPositionspath resolves these$irefvalues 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
_buildStandaloneAircraftEntryand_buildActiveJetwayEntrycompute aircraft parking position and direction directly from stand nose/tail coordinates. The formula isdirection = normalize(NosePosition - TailPosition)andposition = NosePosition - WheelBase * direction, whereWheelBaseis read from the aircraft'sspec(defaults to 0 if unavailable). In_rebuildStaticDataSections, stand positions are parsed once via_parseStandPositions(text)upfront and passed as thestandPositionsparameter 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).
_rebuildStaticDataSectionsnow runs the full pipeline withflights=[], which REBUILDS-from-empty everyflight-plan:REG/aircraft:REG/aircraft-animator:aircraft:REGentry (i.e. removes them) while preserving jetways, radio channels, singletons, and scenery, and still callswriteAclso the cleared file is actually persisted.loadFlightsreturns an empty schedule (a warning log) instead of throwing "No flight data found in ACL", and the UI does not block Save/Save As whenst.flights.length === 0. Because the game strips now-unused types from the flight-lessStaticData.$blobdocscope, the blobdoc type resolves forDateTime/FlightPlanDepartureLeg/FlightPlanStaticItemare gated behindhasFlights(see the "No fallback type numbers" bullet). Covered bytests/integration/zeroflight_save.test.js(clear + preserve jetways / reload empty / re-save) andtests/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 (generateFullAclinparser.js) pre-validates with_validateStandConflicts()(consecutive ARR→DEP pairs must have matching registrations — different aircraft cannot occupy the same stand simultaneously) and passesstandPool= the renderer'ssceneryMaps.standIdToGuidkeys 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$idnamespace, so the pipeline creates per-segment type maps and per-segment_IdMapperinstances for centralized$irefremapping. The resolvedsaveSec(fromaclcfgStartTime, falling back toresolveConfigTime—_saveSecis ignored) is threaded through to_rebuildFlightRuntimeEntitiesfor aircraft position computation. Execution order (critical — 7b MUST run before 7a so jetway detection sees intact$fstrrefvalues):
- 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 MUTATINGflightsin 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 → twoflight-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 toreg-suffix(loop of unambiguous letters/digits, thenX<0..999>); (2) docked-stand occupancy — a dep at a docked stand occupies it from scenario start until itsTakeoffTime(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 withinGAME_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 fromstandPool(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_rebuildJetwayEntriesuses), 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 atFlightPlan.Init()("has neither an arrival nor a departure leg"); every STAR-less arrival getsAirwayfilled with the first STAR of its runway fromapproachCache.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: UIrunTripleValidation(val_star_required, gated on_starRunwayMapbeing non-empty) + servervalidateFlightObjects(missing_star, unconditional). Covered bytests/integration/save_gamecompat.test.js(8 tests) +gamecompat-utils.cjsarrival-no-star.
0b. Voice/language repair (5) — same normalizer: the game's VoiceCatalog throwsInvalidOperationExceptionat level load when an aircraft's captain voice language != the flight'sLanguage(fuzz-discovered at ZGSZ: an-ENcaptain voice on azhflight)._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 withLanguageis 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),pickVoiceForLanguageinflightDefaults.js, andval_voice_language_mismatchinrunTripleValidation; the fuzz gate adds avoice-language-mismatchcheck. Airline/language repair (6) — same normalizer, runs BEFORE (5): the game'sCallsignServiceallocates 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'sLanguagemust 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 foraudio_clips_zh.jsonnext to the level (a CN carrier only gets zh when the airport ships Chinese audio; KJFK/KDCA/EGLC have none). Mismatches setLanguageand bumprelanguaged, then (5) repairs the Voice. A mismatch is what throwsInvalidOperationException: 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 bycreateDefaultFlight,cascadeAirlineLanguage(Cascade 2b inappStore.updateFlight) andval_airline_language_mismatchinrunTripleValidation. - 7a-2. Pre-expand short-form $type refs: Expands bare
$type: Nreferences 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. - 7a-3. Pre-compute flight-plan
$idmap (fpIdByReg): Scans each GATCARC4 segment for its highest existing$idvalue — inflated toentryId + 38forjetway: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 atmaxId + 1 + ifor each registration (sorted). ThisfpIdByRegmap is shared by both jetway rebuild (7b-1) and flight-runtime rebuild (7b-3) so they use identical$idvalues for_flightPlanentries. Jetway inline_flightPlan(emitted first in$rcontent) gets$id: fpId; the top-levelflight-plan:REGentry (middle) becomes{ __iref: fpId }pointing back to it; standalone aircraft (bottom) also use_flightPlan: { __iref: fpId }. All$irefrefs are backward, so_reorderIrefEntriesprocesses them with zero deferrals. - 7b-1. Rebuild jetway entries constructively (
_rebuildJetwayEntries): Replaces the modification-based_resetFrameJetwayDockingStatewith 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 sixRunwayCoordinatorstring[]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$idincl. rebuilt-jetway expansion and pastfpIdByRegids), NOT from entryId+offset ranges — those overlap between adjacent jetways (entryIds 8 apart, template offsets up to 38: jetway:01id(33)=36equals jetway:02id(25)=36) and a duplicate$idthat is an$ireftarget crashes the game's JsonDataReader with a NullReferenceException._buildActiveJetwayEntryregisters old→new$idmappings in the IdMapper only for sub-object$ids that actually existed in the original entry (an empty jetway declares onlyentryId,Progress,DockingAircraft,DockingDoorIndex), so phantomentryId+4..38mappings 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. - 7b-2. Re-expand bare $type refs (belt-and-braces): Re-runs
_expandShortFormTypeson 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. - 7b-3. Rebuild flight-plan, aircraft, and animator RuntimeEntities (
_rebuildFlightRuntimeEntities): Deletes ALL existingflight-plan:REG,aircraft:REG, andaircraft-animator:aircraft:REGentries 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$idfor iref remapping. Pass 2: rebuildflight-plan:REGentries as structured objects with$type,__vfor DateTime ticks,__fstrreffor static-item references. Jetway-mapped flights (reg has a jetway entry) get$v: { __iref: fpId }instead of a full flight-plan object. Pass 3: buildaircraft:REGentries via_buildStandaloneAircraftEntryusing structured objects (spec, dynamics data, runway coordinator, position/direction — all JS objects with sentinel properties). Pass 4: buildaircraft-animator:aircraft:REGentries. No more hardcoded ordering: all entries, including kept entries (raw text preserved), are wrapped inOdinEntryobjects and passed through_reorderIrefEntriesfor topological sort by$irefdependency. Entries with no unresolved$ireftargets emit in input order; entries waiting on a forward$iddefer until it appears. - 7c. Remove orphaned RuntimeEntities (
_removeOrphanedFlightEntities): Removes/renames entries whose$kkey (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$kvalue using the renameMap. Handles fallback viaStaticItem.$fstrreffor already-corrupted saves. Updates$rlengthcounters. Safe because aircraft$idnamespace lives in jetwayDockingAircraft(preserved by 7b-1), not in the orphanedaircraft:REGentries. - 7d. Cleanup EventLog LatestEvents (
_cleanupEventLogLatestEvents): On every save, fully clears theLatestEventsdictionary insidesingleton:event-log.$v.LatestEvents— sets$rlengthto 0 and clears$rcontentto[]. with a full wipe, preventing staleaircraft:REGentries from accumulating across saves. Invisible to step 7c because it lives deeper insidesingleton:event-log.$v.LatestEvents.$rcontent. - Centralized $iref remapping (
_IdMapper.remapIrefs): After all rebuild steps, applies old→new$idmappings collected by per-segment_IdMapperinstances. Corrects$irefreferences in preserved entries that pointed to removed/rebuilt objects (e.g. flight-plan$idvalues that changed during 7b-3). The_IdMapperclass acts as a union-find structure with transitive chain resolution and back-to-front positional replacement. Before remapping, every$idowned 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:615that points to a kept radio-channel stays 615 instead of being remapped to a rebuilt jetway id) is never rewritten. - 7a. $fstrref cleanup & remapping (runs AFTER 7b): IndexOf-based positional scanning for
$fstrref:"flight-plan:REG"references — replaces withnullfor deleted registrations, remaps to the new registration for renamed ones. Runs after 7b so the jetway rebuild step can detect stale DockingAircraft via intact$fstrrefvalues before they are nulled.
- Final safety net — ascending
$idrenumbering (renumberAclIds, inwriteAcl): after ALL rebuild steps and remapping, every segment document gets its$id/$irefrewritten 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 arenameMapthat feeds into steps 7c/7a to avoid unnecessary data loss. - New constants:
DEPARTURE_TAXI_SECONDSandARRIVAL_TAXI_SECONDSinsrc/utils/constants/timing.jsprovide 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,default400s both (fallback for airports without data, e.g. KDCA whose saves are clean starts). Measured viatests/_debug/extract_aircraft_times.js(decodes all.aclunderAirports/<ICAO>/Levels/, extracts per-aircraft_departureTakeoffTime/_arrivalInBlockTimefrom RuntimeEntities +OffBlockTime/LandingTimefrom StaticItems →aircraft_times_report.tsv) thentests/_debug/compute_taxi_constants.js(per-airport median/avg, prints ready-to-paste constant blocks). Used by_computeTakeoffTicks/_computeArrivalInBlockTicks— lookup isTAXI_SECONDS[icao] ?? TAXI_SECONDS.default.TAXI_SPEED,POSITIVE_TAXI_ACCEL,NEGATIVE_TAXI_ACCEL(also fromtiming.js, re-exported viaconstants.js) supply aircraft dynamics values for_buildActiveJetwayEntryand_buildStandaloneAircraftEntry.DYNAMICS_POSITIVE_TAXI_ACCEL(3) /DYNAMICS_NEGATIVE_TAXI_ACCEL(-4) insrc/utils/constants/aviation.jsare the boosted dynamics override, verified against healthy game saves:DynamicsPositiveTaxiAcceleration/DynamicsNegativeTaxiAccelerationuse 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_TTLchanged 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'svoice_catalog.jsonlanguage differs from itsLanguage(val_voice_language_mismatch, zh+en) whenairportValues._voiceLanguagesis present — the game's VoiceCatalog throwsInvalidOperationExceptionat level load otherwise. Uses the same map thatpickVoiceForLanguage(src/store/flightDefaults.js) uses to keep new flights consistent, and thatFlightTable.jsxuses to filter the Voice dropdown to the row'sLanguage(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 (cascadeLanguageChangeinflightCascade.js, applied as Cascade 4 inappStore.updateFlight, skipped when Voice is explicitly part of the same update). Airline/language consistency:runTripleValidationalso flagsval_airline_language_mismatchwhen a flight's callsign airline requires a different captain language (airportValues._airlineCountriespresent) — the game'sCallsignServiceonly records a callsign for one captain language (CN → zh, every other carrier → en), so a mismatch throwsfailed to allocate callsign … for crew voice …at load. New flights default Language from the airline (languageForAirlineinsrc/utils/airlineLanguage.js, falling back to the airport region), and changing the airline cascades Language + Voice (cascadeAirlineLanguage, Cascade 2b inappStore.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 exposesget_airport_info.airlineLanguages(per-airport code → required language) +languageRules,buildConstraintscarriesairlineCountries/voiceLanguages/languagesfrom the renderer'sairportValues,validateFlightObjectsrejectsairline_language_mismatch, andapplyCascadesmirrors Cascade 2b. The fuzz gate (gamecompat-utils.cjs runChecks) reportsairline-language-mismatch(fedairlineCountries+languages), andfuzz-save.spec.mjsgenerates 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), servervalidateFlightObjects(electron/api-server.js), and the clock-popover boundsgetTimeValidationBounds(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_configStartTimestays strict, and timeline/runway-change bounds are NOT relaxed.get_airport_inforeports the effective bound astimeRules.maxTime. - Blobdoc scope-aware $type expansion: The
_expandShortFormTypesfunction was refactored into_expandWithBlobdocScopes(recursive scope-aware walker that identifies$blobdocentries, extracts their type declarations, and recurses for nested blobdocs) +_replaceBareTypeRefs(single-scope regex expander). Each$blobdocsection has independent type numbering, so bare$type: Ninside a blobdoc must expand using that blobdoc's own type declarations — not the outer document's. - GameTime.CurrentDateTime parsing:
extractCurrentDateTime(aclText)triesGameTime.CurrentDateTimefirst (snapshot time, parsed from nested ticks), falls back toMetaData.BaseTime. Used byresolveConfigTimeto override config startTime with post-warmup time (rounded UP to the next whole minute viaroundSecondsToMinute(cdt.secSinceMidnight, true)— seetimeUtils.js). - Structured object entry building:
_buildStandaloneAircraftEntry, flight-plan entries, and animator entries now build structured JS objects with sentinel conventions (__vfor bare values,__iref/__fstrref/__eref/__guidreffor Odin references) rather than joining template-literal text strings. These are serialized viaserializeUnityJson()which handles indentation,$type/$idordering, 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') —serializeUnityJsonadds the surrounding quotes automatically. $ireftopological sort (_reorderIrefEntries+OdinEntryclass): Uses a topological sort of all RuntimeEntities entries.OdinEntryis a lightweight wrapper around a parsed entry with lazy-serialized text, lazily-computed$id/$irefarrays._reorderIrefEntrieswalks entries in a single pass: self-contained entries (all$ireftargets satisfied by their own$ids or inexternalIds) emit immediately; entries with unresolved$ireftargets are deferred until the declaring$idis seen.externalIdsis pre-seeded from the parent dictionary structure (comparer, etc.) so references to entries outside$rcontentare never misidentified as forward refs. Circular/missing$idreferences are logged as errors and appended as fallback.- Odin recursive-descent parser (
parseOdinValue,parseOdinObject,parseOdinArray): A complete string-aware recursive-descent parser added toacl_json.jsthat 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\uXXXXescape handling. No regex on the parse path for correctness. Used primarily by_reorderIrefEntriesto extract$id/$irefmetadata from kept entries — the entry's original text is preserved for output. Export throughsrc/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_fixTypedValuesdoesn't recursively handle all edge cases. Only metadata ($id/$irefvalues) is extracted viaparseOdinObject— the text is never modified. - Pre-computed
fpIdByReg— unified flight-plan$idacross rebuild steps: Both_rebuildJetwayEntriesand_rebuildFlightRuntimeEntitiesreceive the same pre-computedfpIdByRegmap, ensuring the jetway inline_flightPlan, the top-levelflight-plan:REGentry, and the standalone aircraft's_flightPlanall share one canonical$idper registration. The pre-computation scans each segment formaxIdand assigns IDs atmaxId + 1 + ifor sorted registrations, guaranteeing no collision with existing entries.nextIdis advanced past the pre-computed flight-plan IDs before aircraft/animator entries are built. - Shared empty-array
$irefdedup via dynamic allocator: The sharing (rpSharedArr/rpStrArrin_buildStandaloneAircraftEntry,sharedEmptyArrayInner/sharedStrArrInnerin_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$irefthem (backward refs only — zero deferrals in_reorderIrefEntries). One canonical emptyAircraftEvent[]/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. fpTypeStrregex tightened: The$typeextraction in_rebuildStaticDataSectionsnow only accepts the expanded"N|TypeName"form. A bare"$type": Nwould not self-register in the blobdoc scope, causing "unknown type id N" during Odin binary encoding. The defaultfpTypeStris 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 forJSON.parse; (1.5)_fixMissingCommas— inserts missing commas between properties;_collectBareValuesnow handlesnull/true/falseas bare values and recursively fixes typed values inside nested objects and arrays. - SPECIAL_KEYS updated: Added
__iref,__fstrref,__eref,__guidrefto the set insrc/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._makeJwTypeResolveris the single shared resolver:_buildStandaloneAircraftEntryand_rebuildFlightRuntimeEntitiesuse unquoted mode (bareN|Name, the serializer quotes it),_buildActiveJetwayEntrythe 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 declaresApproachDynamicsParamsmust not crash the save._buildStandaloneAircraftEntrygates State=5 emission onscopeDeclaresApproachDynParams(scan of declared names) — State=5 degrades to STAR State=30 when the scope can't represent final approach._buildActiveJetwayEntrynever resolvesDYN_PARAMS_TYPE(the jetway path emits no approach dynamics). Exception 2 (v5 ZGSZ): genuinely missing jetway/standalone types get a fallback allocation —allowFallback:truemintsmaxId+1and self-declares via full-form"$type":"N|Name"(see jetway$typebullet).STRICT_JETWAY_TYPESremain strict. Exception 3 (lean checkpoint-frame scope):_rebuildFlightRuntimeEntities's sharedsegTypeResolve(thefp/dt/animator resolver) also passesallowFallback:true— a checkpoint frame that legitimately carries only jetway/flight-plan runtime entities omits the aircraft/animator type graph, so the eageranimTypeFull = 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$typebullet). The standalone-aircraftcatchalso logs the FULL[TYPE-ASSERT]message (canonical name + scope dump) instead of truncating to the first line. The old_freshNumcounter is gone. - No hardcoded type numbers — resolve, or re-declare when stripped:
_rebuildStaticDataSectionsresolves 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 theKJFK_peakarrivalfuzz: an all-departure save emits no arrival leg, so the game stripsFlightPlanArrivalLegfrom the blobdoc type table; a later save that reintroduces an arrival aborted withblobdoc type "…" not in bdTypeMap. Applies toDateTime/FlightPlanDepartureLeg/FlightPlanStaticItemtoo. 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. Inapproach.js, type keys resolve viaresolve(key, fullName)that throws if the key is missing fromtypeNums. Hardcoded fallback defaults (e.g.33for 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):
buildApproachAircraftBlockandbuildState5AircraftBlockresolve every$type— includingvec3(UnityEngine.Vector3) andstrArr(System.String[]) — from the per-filetypeNumsmap. 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 viaresolve(key, fullName). - Base-time extraction is
$type-agnostic:_extractBaseDateFromTextmatches"BaseTime": { "$type": <any-id>, ticks }and WorldState flight times with"\$type": \d+— DateTime ids are per-scope, so a hardcoded$type: 3silently missed files that declare DateTime elsewhere. CANONICAL_SCOPEfixture for builder unit tests:tests/integration/_canonical_scope.cjsexports the ZSJN_leisure_1 frame-scope type declarations (ids 0–51). Unit tests that drive_buildActiveJetwayEntry/_buildStandaloneAircraftEntrymust pass it asjwTypeMap/segTypeMap— strict resolution asserts[TYPE-ASSERT]on any canonical name the scope doesn't declare, sonew Map()no longer works. Derived viatests/_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
_buildStandaloneAircraftEntryand_buildActiveJetwayEntrythrew whenspec.DockingPositionswas 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 validDockingPositions = [{x:2.5,y:0,z:0,w:1}], so this hard error only fires whenspec.DockingPositionsis present but genuinely empty — the!speccase is now the[SPEC-FALLBACK]path. - Spec fields are REQUIRED —
requireSpecFieldwith fallback spec (v5):requireSpecField(log, ctx, field, value, spec, fallback)(insrc/acl/approach.js, exported) asserts when a resolved field value is missing. The fallback spec itself is built fromDEFAULT_*(see spec-fallback bullet) andrequireSpecFieldvalidates the fabricated spec's fields — soDEFAULT_*are both diagnostic and emitted values for the missing-spec path. Applies on intake (extractSpecificationDBvia 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_buildActiveJetwayEntryand_buildStandaloneAircraftEntrynow fabricate a spec fromDEFAULT_*constants whenspecDB/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.debugper 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 likeCOMAC C-919/A320ceovariants 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 isspec.AerodromeCoderesolved through the normal spec chain — a spec missing it asserts viarequireSpecField(refusing fallback 67). - Every rebuilt jetway sub-object
$idfrom the segment's dynamic allocator: In fails.acl,jetway:09 id(15) = 190 + 15 = 205equalsjetway:12 id(3) = 202 + 3 = 205; a duplicate$idthat is an$ireftarget makes the game's JsonDataReader bind the first declaration (first-wins), soaircraft:B-5380A's$iref:205resolved to aString[]wrapper → "Data layout mismatch; skipping past node boundary when exiting array". Now_buildActiveJetwayEntryallocates 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 oldentryId+offsetid in the segment_IdMapper, so the centralized remap step rewrites$irefreferences 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 meansContextCross.Aircrafts.Aircraft(writing id 6 forDockingDoorIndexmisbinds it)._makeJwTypeResolver(jwTypeMap, quoted, opts)(exported fromflight_plans.js) builds a name→id lookup from the segment's declarations and returns full-form"N|Name"strings (quoted=truefor template text,quoted=falsebareN|Namefor 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 idmaxId+1beyond 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._buildStandaloneAircraftEntrycalls withallowFallback:true;_buildActiveJetwayEntrywraps the sharedjwTypeResolvewith a fallback resolver that retries on[TYPE-ASSERT]and reflects the allocation back intojwTypeMapso siblings see it. One resolver instance per segment is shared by ALL rebuilt entries (active + empty). Top-level RuntimeEntity fields resolve the same way.ApproachDynamicsParamsis NOT resolved here (dead eagerDYN_PARAMS_TYPEremoved — crashed departure-only saves; the jetway path never emits approach dynamics). - State=5 aircraft in standalone entries:
_buildStandaloneAircraftEntrynow supports State=5 (final approach, DynamicsState=2) aircraft. WhenaircraftState === 5, the entry getsApproachDynamicsParamsinstead ofFlyApproachDynamicsParams, withPathPointList,TouchDownPosition,ApproachDirection, andInitialPositionfields. State=5 params are resolved fromapproachCache.state5ParamsMapby appKey then runway — throws a descriptiveErrorwhen no cached entry exists (state5ParamsMapmust always be populated at cache-build time). Scope gate: therawTargetDist >= flyLenState=5 decision also requiresscopeDeclaresApproachDynParams— when the segment's type table omitsApproachDynamicsParams(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_TOWERin waiting commands: State=5 (final approach) standalone aircraft entries now includeCMD_CONTACT_TOWERin their_waitingForCommandsarray (__v: [{ $id: id(25), $type: T.cmdArr, $rcontent: [CMD_CONTACT_TOWER] }]), giving the arrival an initial "contact tower" command.selectedRunwayExitIndexis0(was-1in earlier versions). State=30 (before IAF) and State=10 (DEP parked) entries keep an empty$rcontent: []. (State=5 is only emitted when the scope declaresApproachDynamicsParams— see the State=5 bullet.) - DEP aircraft in RuntimeEntities: DEP flights now generate
aircraft:REG+aircraft-animator:aircraft:REGpairs during_rebuildFlightRuntimeEntities. For DEP flights with an active (populated) jetway, the aircraft entry is$v: { __iref: jetwayAcId }— pointing into the jetway's inlineDockingAircraft— 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), andgearRatio: 1(gear down). TheradioChannelIdreference defaults tonullwhen no radio-channel entry exists. Standalone DEP aircraft still get a position from_resolveStandPosition. - FlightPlan unified via
$irefchain: The_flightPlanfield in_buildStandaloneAircraftEntryis now always{ __iref: fpId }— a thin reference to the flight-plan$idrather 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 inlineDockingAircraft._flightPlan(jetway-mapped flights), or (b) in the top-levelflight-plan:REGentry (standalone flights). ThefpIdis pre-computed in step 7a-3 and shared across both rebuild steps, so the$irefchain 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 thefpIdByRegflight-plan id block on a purge save, sorenumberAclIdsbindsaircraft:REG._flightPlanto the jetway'sDockingAircraft— Unity:InvalidOperationException: Aircraft 'aircraft:REG' has no flight plan reference(ZGSZ_leisure_2aircraft:B-05Z7, KDCA_leisure_2aircraft:F-TGSN, ground fuzz). The regression test drivespatchSceneryBlob → writeAcl → generateFullAclon the_debugZSJN level and asserts (1) no runtime Jetway is fabricated and (2) the savedRuntimeEntitiesscope has unique$ids and everyaircraft:*._flightPlan$ireftargets its ownflight-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$irefissues 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
$kprefix 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/$irefmetadata viaparseOdinObject. typeNameIndexin approachCache:buildApproachCacheexportstypeNameIndex(invertedtypeMap: name→id) andfileTypeNameIndexes(per-file name→id Maps). Built bybuildTypeNameIndex(typeMap)inapproach.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 sixRunwayCoordinatorfields),AircraftEvent[](_receivedEvents),ECommand[](_waitingForCommands). All three caches share ONE dynamic$idallocator per segment ({ v }seeded atsegMaxId + 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) pluscanonicalEmitted(whether the inline definition has been written yet) handle the cross-ordering between the two rebuild steps. 7b-3 also advancesnextIdpast every claim so aircraft/animator ranges never reach them. - Radio-channel type resolution from StaticItems: During
_rebuildFlightRuntimeEntities, the code parsesStaticItems.$rcontentvia the tokenizer to build aradioChannelTypeMap(key → Type: 2=GND, 3=TWR, 5=APP). Kept radio-channel entries inRuntimeEntitiesare matched by key against this map to identify separatetowerChannelId(Type 3) andapprChannelId(Type 5). - State-dependent radio channel selection:
_buildStandaloneAircraftEntryselects between tower (State=5, final approach) and approach (State=30, before IAF) based on aircraft state. State=10 (DEP parked) stays null. Both_radioChanneland_jurisdictionRadioChannelare set to the same active channel. - gearRatio in animator blocks:
buildAnimatorBlockaccepts agearRatioparameter (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:
resolveApproachProcedureDataresolves touchdown position from the runway entry's dedicatedTouchDownPoint $irefpointer instead of extending past the last approach-procedure point by a fixed distance. The$ireftargets a taxiway-node entity at the landing threshold. Throws on missing or unresolvableTouchDownPoint. - routeName in approach procedure data:
resolveApproachProcedureDataextracts and returns theNamefield from the approach procedure's route entry asrouteName._buildStandaloneAircraftEntryuses thisrouteNamefor theApproachRoutefield. - State=5
_routeuses approach procedure name: In_buildStandaloneAircraftEntry, State=5 aircraft (final approach) set the_routefield tostate5Params.routeName(e.g."RNAV ILS Z Rwy 19") instead of the STAR name (e.g."UBSS6W"). Non-State-5 entries keepstar; the guard isaircraftState === 5 && state5Params && state5Params.routeName. The jetway DockingAircraft template is unaffected (jetway aircraft are always State=10 parked). - hintPosition for approach variant selection:
computeFullTerminalPathpasses the last FlyApproach point (IAF) ashintPositiontoresolveApproachProcedureData. 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:
_buildStandaloneAircraftEntryincludes the touchdown distance intotalLen(the denominator forrawTargetDistIAF boundary). This ensurestotalLenmatches the path length that TAT was calibrated for — scenery-derived TAT includestdDistfromcomputeFullTerminalPath. Without this,rawTargetDistused a shorter denominator, biasing the IAF boundary toward State=30 for aircraft near the runway. - Hardcoded InitialPosition.Y:
_buildStandaloneAircraftEntryhardcodesInitialPosition.Yto15.24(the standard ILS approach ceiling at 100 m/unit scale = 5000 ft) instead of usingstate5Params.initialPosition.y || 0. The stored path points have Y=0 (game engine computes altitude internally from touchDownPosition + path distance), butInitialPositionstores the approach ceiling altitude directly. Every original game file uses 15.24 regardless of airport. - touchDownPosition from state5ParamsMap:
_buildStandaloneAircraftEntryresolves touchdown position fromapproachCache.state5ParamsMap(by appKey then runway) instead ofapproachCache.runwayThresholds.runwayThresholdsreturns an array of two threshold positions, not a single touchdown point — passing it tocomputePositioncaused_vec3Distto 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:
_extractVector4ArrayusespreprocessUnityJson+JSON.parsewith the__vsentinel 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 theMetaDatasection value with the tokenizer, splices rebuiltWeatherFrames/WindFrames/RunwayTimelinesub-sections in place (keyStart→valueEnd, keeping the"MetaData"key intact), and writes back viawriteAcl. 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). ConsequentlyWeatherEditor.jsx/WindEditor.jsxalways render all frames, theirTimeCells have no min/max bounds, and save validation never checks weather/wind times. OnlyRunwayTimelineis window-bounded by design (auto-pruned on load inEditorScreen.jsx,val_runway_change_boundsat save). The.tl-listCSS grids for weather/wind aredisplay: contentsgrids — 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" }(seereferences/acl-format.md). - RunwayTimeline type-number conflict fix: When a file's Timeline is empty,
_metaRunwayfalls back totlTypeNum + {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[], mscorlibcollides with thetlElemTypeNum = 12fallback whentlTypeNum = 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
findKeyAtDepthstring-awareness fix:findKeyAtDepth(start, end, key, targetDepth)only matches the search key at an opening quote that is OUTSIDE a string (!inStringat"), instead of checking for the key text anywhere at the target depth. The key-match check moved into the quote handler before theinStringtoggle; escaped quotes (\") are still skipped by the backslash rule. - State=5 ProgressRatio=0:
_buildStandaloneAircraftEntrywrites constantProgressRatio: 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 bytests/integration/state5_output_pr.test.js. - New flights:
isDepartureflag +AirlineNamecode: Newly created flights (UI factorycreateDefaultFlight, MCPcreate_flights) now carryisDeparture(true for departures) andAirlineName= the 3-letter airline code (the game stores codes, e.g."CDG"/"UAL", not display names). All 7 arrival/departure classification sites inflight_plans.js(static-items leg, stand-conflict validation, jetway rebuild, turnaround winner, runtime flight-plan/aircraft entries) go through_isDepartureFlight()—isDeparture === trueOR non-emptyOffBlockTime— so a missing flag can never silently serialize a departure as an arrival again. The serializer also falls backAirlineNameto the callsign prefix. AirlineCode edits syncAirlineNamevia the cascade (rendererflightCascade.js+ serverapplyCascades). Covered bytests/integration/new_departure_save.test.js(saves flag-less flights, asserts the departure leg + code roundtrip).
Key Rules for Agents
- React + Vite + zustand stack. Frontend uses ESM, JSX, and React hooks. No global-scope scripts.
- No TypeScript. This is plain JS/JSX. Do not add
tsconfig.jsonor convert files to.tsx. - No linter/formatter. Do not add ESLint, Prettier, or any linting config unless explicitly asked.
- Testing uses Vitest (component) + Playwright (E2E) + Node.js (integration). Component tests go in
tests/components/, electron-backend tests intests/electron/(use@vitest-environment node+require.cachepriming for CJS modules that require ESM SDKs), pure backend/util + hook tests intests/acl/andtests/unit/, E2E specs intests/e2e/, integration scripts intests/integration/. Do not add Jest or Mocha. - No npm dependencies for core logic. The app uses only Node.js built-ins. Justify any new dependency.
- Preserve CommonJS for backend.
electron/andsrc/acl/userequire()/module.exports. - ESM for frontend.
src/components/,src/hooks/,src/store/,src/utils/useimport/export. - IPC for all file I/O. The renderer never touches the filesystem. All reads/writes go through
electron/main.jshandlers. - Return
{ success }from IPC. Every handler returns{ success: true/false, error?: string }. _underscore= private in backend. Prefix internal functions with_. Export them anyway for testing.snake_case.jsfor backend,PascalCase.jsxfor components. Match existing conventions.- No inline
style={{}}. Always extract CSS to the component's.cssfile. - One
.cssper component. Match the component filename. - No
dangerouslySetInnerHTML. UsesafeHtml()fromsrc/utils/safeHtml.jsxto render i18n strings containing<strong>,<em>, or<br>as safe React nodes. It sanitises unknown tags and attribute injection as plain text. - Use
readAclText()/writeAcl()for all ACL I/O. Never usefs.readFileSync(path, 'utf-8')orfs.writeFileSync(path, text, 'utf-8')for.aclfiles. Always go throughgatcarc.jsto transparently decode/encode the GATCARC4 binary container. This applies toelectron/main.js, all backend modules, and all integration tests. - Use
resolveConfigTime()for config time display. Every consumer of ACL config time (startTime/endTime) must useresolveConfigTime(rawText)fromsrc/acl/config.js— not_extractConfig()directly.resolveConfigTimeapplies the GameTime.CurrentDateTime override (post-warmup time), rounded UP to the next whole minute viaroundSecondsToMinute()fromsrc/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. - Update the facade. New backend modules must be re-exported through
src/acl/parser.js. - Build locally with
node build.json Windows, nevernpm run build:win(local only — CI usesnpm run build:winfor cross-platform builds). - Bump
CACHE_VERSIONwhen cache.json schema changes. Any change to the structure ofapproachData,fileTypeMaps,state5ParamsMap,taxiwayPaths,sidPaths,missedAppPaths,airwayNodes,starWaypoints,allAclTextsmerge, or new top-level keys in cache.json MUST bumpCACHE_VERSIONinsrc/utils/constants/timing.js(re-exported viasrc/utils/constants/index.jsandsrc/acl/constants.jsfor CJS backward compat). Stale caches silently corrupt saves. Note:saveTimeOffsetsandtypeMapare build-time-only and are no longer serialized. - Version tags for releases. Version tags use
v<MAJOR>.<MINOR>.<PATCH>format. Keeppackage.jsonversionand the git tag in sync. The user decides when to bump; you can re-tag the same version to include new changes. - 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.
- UDP listener lifecycle is managed by main process.
startUdpListener()is called inapp.whenReady()aftercreateWindow(),stopUdpListener()inwill-quit. The listener auto-reconnects on socket errors (2s delay). Do not create multiple listeners or start/stop from the renderer. - Map windows are separate BrowserWindow instances. They are NOT React components in the main renderer. Track them in
groundMapWindows/airMapWindows/flightStripsWindowsMaps (keyed by ICAO). Always check for existing windows before creating (focus if exists). Clean up Map entries in theclosedevent handler. Each window loads the same Vite SPA with query params (?window=groundMap&airport=XXXX,?window=airMap&airport=XXXX, or?window=flightStrips&airport=XXXX). - UDP state push handles cleanup. The
udp-aircraft-stateIPC event is pushed to ALL open map windows every 200ms. Map window components subscribe viauseUdpAircraftState()hook which wrapsonUdpAircraftState/offUdpAircraftState. Always unsubscribe inuseEffectcleanup to prevent stale callbacks or memory leaks. - 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_VERSIONvalues, 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.