---
name: ac27-editor
description: 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.
---

# AC27 Editor — Repo Skill

## Project Identity

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

## Architecture Overview

| Layer | Key Files | Details |
|-------|-----------|---------|
| **Electron Main Process** | `electron/main.js` | Creates BrowserWindow (1400×880, min 1024×640), contextIsolation: true, nodeIntegration: false, 65 ipcMain.handle() endpoints, all file I/O/dialog/caching |
| | `electron/updater.js` | Auto-update: HEAD check (R2 ETag), MD5 comparison, file-based logging to `<userData>/updater.log`, `resolveTargetExe()` (dev-mode support), exe download, batch script generation. **Both variants auto-update through ONE `/editor` route** — the voice build (detected via `resources/voice-stt-vosk.js`, see `isVoiceBuild()`) announces itself with an `X-AC27-Variant: voice` header (`variantHeader()`), so the Worker serves `AC27EditorVoice.exe(.md5)` instead of the normal build's objects |
| | `electron/api-server.js` | HTTP API + MCP (port 31415) |
| | `electron/cloud-llm.js` | Multi-vendor cloud LLM chat |
| **Preload Bridge** | `electron/preload.js` | contextBridge exposing `window.electronAPI` with ~91 methods (checkForUpdate, downloadUpdate, installUpdate — no longer exposes skipUpdate), onStoreApiUpdate/offStoreApiUpdate for MCP bridge, each method = ipcRenderer.invoke(channel, ...args) |
| **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 |
| | `SetupScreen`, `BrowserScreen`, `EditorScreen` | Three-screen SPA |
| | `BrowserScreen` | useTooltip (shared tooltip hook), VideoReplaceOverlay (menu video replacer), VideoBackgroundModal (video replace/restore modal), BrowserHelpOverlay (help overlay), Livery install |
| | `EditorScreen` | FlightTable, TimelineEditors, CellEditor, SearchBar, TutorialOverlay |
| | `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) |
| | `src/store/flightDefaults.js` | Pure helpers for new flight creation: random airline code (audio → AirlineCode dropdown → AirlineName → 'NEW'), cascaded aircraft type + 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'), Voice randomized from the airport's dropdown pool. 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→type/reg, runway→STAR) |
| **ACL Backend** | `src/acl/parser.js` | **Facade** — main.js imports ALL backend modules through it only (17 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). `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/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, htmlUtils.js, csvIo.js, zipUtils.js, logger.js, safeHtml.jsx (renders i18n strings w/ allowed HTML tags `<strong>`, `<em>`, `<br>` as safe JSX nodes), debugLog.js (gated debug logging via localStorage + URL query param toggle) |

**Map Windows (separate BrowserWindow instances):**
- `electron/main.js` manages `groundMapWindows` / `airMapWindows` / `flightStripsWindows` Maps (keyed by ICAO) + `selectedCallSigns` Map (synced selection state)
- Each map window loads the same Vite SPA with query params (`?window=groundMap&airport=XXXX`, etc.)
- `electron/udp_listener.js` listens on `127.0.0.1:20266` for binary aircraft telemetry (10 Hz) and sends commands on `127.0.0.1:20267`
- Live aircraft state pushed to all open map windows (ground + air + flight strips) at 200ms interval via `udp-aircraft-state` IPC event
- Map window click-to-select goes through centralized `select-aircraft-in-map` IPC
- **Radar rendering:** AirMapWindow applies **display-level STAR/SID dedup** (`src/utils/starDisplay.js`): ZGSZ-style airports name each runway variant separately (e.g. `SAREX4.34L/34R/33/16L/16R/15` all the same STAR base) — `dedupeStarPathsForDisplay` merges them under the base route name (representative longest path + all served runways) for rendering/labels only; underlying per-runway cache keys stay intact for aircraft lookup / game save. Then `filterDedupedStarPathsByRunway` filters merged groups + preserved per-runway variants by the active runway set. GroundMapWindow computes an **auto-fit `contentViewBox`** over the fetched taxiway/stand/runway/area geometry (airports authored far from the world origin like ZGSZ would otherwise show a blank origin-centered box), falling back to `GROUND_MAP_CENTER_OFFSET` + `dataBounds`, and snaps the radar view to the airport once geometry arrives.
- **Patch command composer (`FlightPatchCommandBar.jsx`):** when a strip on the approach channel (`controlSeat=5`) is selected, a command-line-style composer pops up above the strips bar. Click/drag-driven options: `Fly Heading` (heading-only override — opens a 001–360 slider defaulted to the aircraft's live telemetry heading via `atan2(noseDirection.x, noseDirection.z)`, no fixed value list), `Fly Altitude` (1000-ft slider, hidden without live telemetry; China `Z*` airports — `isChinaIcao`, the same Z-convention `flightDefaults` uses for the 'zh' language — switch to a 300–2700 m slider in 300-m steps, converted back to feet before Send), `Fly Speed` (180–240 kt slider, hidden without live telemetry), `Fly Waypoint` (2026-08-15 — the aircraft's current-approach waypoint list from the STAR telemetry and the approach cache `_starWaypoints`, left-to-right in route order entry→IAF; picking a fix sends `update_heading` at the bearing from the aircraft's LIVE position to the fix, recomputed at Send — byte-identical to the voice pipeline's "fly direct to X"; OVERRIDES a composed Fly Heading, blacking it out), or `Clear for Approach` (supersedes the whole composed chain). Each panel's **Add** chains commands; **Send dispatches the chain IN ORDER — N sequential `send-patch-command` frames to the AC27Approach plugin** (see the `ac27-approach` skill). Heading/waypoint frames carry a fixed rate of 3°/s of game time (`TURN_RATE_DEG_S` — the plugin's `update_heading` smoothing; for `clear_for_appr` it drives the bounded de-snap: the nose rotates toward the IAF at that rate until capture, then the game's steering flies the final turn). Gated on `checkCommandCapability`/`checkBepInEx` (plugin only exists with BepInEx installed) + `witchMode` off; Escape/Cancel dismisses. Approach-only: aircraft handed to tower (seat 3) lose the popup automatically
- **Voice command input (live, patch-command vocabulary):** `useVoiceCommands.js` + `VoicePTTButton.jsx` provide push-to-talk voice commands for the Flight Strips window. Callsign-first flow: spoken airline name → ICAO code (via `AIRLINE_CODE_MAP`) + spoken numbers → digits → match against UDP aircraft → greedy pattern match against the patch-command vocabulary (heading/altitude/speed/clear-for-approach/fly-direct-to-waypoint, EN + ZH). **Speech backend (2026-08-06): offline vosk** — `electron/voice-stt-vosk.js` (spawned by `electron/voiceSttWorker.js` via `process.execPath` + `ELECTRON_RUN_AS_NODE=1`), mic capture by sox (`bin/sox`, default recording device, 16 kHz mono), decoding by koffi + vendored `bin/vosk/libvosk.dll` (`electron/voskFfi.js`), **grammar-constrained** to the patch vocabulary (`electron/voice-grammar.json`, generated from the live parser tables — EN words + ZH chars), **dual EN/ZH** models with confidence-winner picking. The earlier Windows System.Speech PowerShell worker and the Web Speech API (broken in Electron — Chromium streams mic audio to Google's speech API, shut down for shell environments) are gone. Fuzzy matching (`voiceFuzzy.js` + acceptance table) remains as post-processing safety net. Full docs in `references/map-windows.md` "Voice Command Input"; `scripts/fetch-vosk-model.mjs` downloads the models (gitignored); `scripts/voice-stt-test.mjs` is the TTS round-trip harness. The voice build ships the LARGE en model (`vosk-model-en-us-0.22` — the small en-us-0.15 was ditched 2026-08-06 as too inaccurate) + the small zh model (`vosk-model-small-cn-0.22`); full grammar coverage since 2026-08-06.
- **Mod install/update prompt (2026-08-18):** when the window-open `checkCommandCapability` resolves with the AC27Approach plugin **missing** (`pluginInstalled=false`) or **outdated** (`pluginUpToDate===false` — local MD5 vs R2 ETag), the Flight Strips window pops an overlay (`#map-help-overlay` shell + Modal's own `#modal-actions` container with `.btn-cancel` / `.btn-confirm` button classes) with **Install / Cancel** and an optional **"Do not show again"** checkbox. Install routes through the existing tray flow (`handleLoadDll` — R2 download overlay via `ApproachPluginInstallOverlay`, or the Debug-Mode notice when BepInEx itself is absent); Cancel/X/Escape/backdrop dismiss. Dismissal persistence (`STORAGE_KEY_APPROACH_PROMPT` in `src/utils/constants/ui.js` = `ac27_approach_prompt_dismissed`) is **state-scoped**: `'missing'` or `'outdated:<md5>'` — dismissing one outdated build still re-prompts for the next release. i18n: `mod_prompt_*` keys (zh + en in `src/utils/i18n.js`), Cancel reuses `modal_btn_cancel`.

## Reference Files

This skill uses **progressive disclosure** — the central SKILL.md (this file) stays under 200 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 |
| MCP / AI Agent Integration | `references/mcp-integration.md` | Working on API server, MCP tools, SSE endpoint, store-update IPC bridge, AI agent control flow |
| 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, useCrossWindowSelection, useWitchAnimation, useKnobPositions), ControlSidebar with SpinKnobs, witch mode, cross-window selection sync, drag reorder.
- **`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, 13-point validation (time bounds allow end +30 min grace), SSE/JSON-RPC endpoints. Also covers `electron/cloud-llm.js` (DeepSeek/Gemini/Claude/Codex chat with tool calling) and the `ChatPanel` React component.
- **`dev-commands.md`** — All npm/node commands: component tests, E2E tests, integration tests (with `--acl` and `--root` variants), local build (`node build.js`), GitHub release workflow.

## Schema

All `.acl` files use the **v4 schema** — a GATCARC4 binary container (introduced by a game update). v4 is the only format: there is no schema detection and no `isV4` flag anywhere in the code. Every parse/extract/build path 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). `readAclText()` decodes to Odin JSON text; `writeAcl()` re-encodes preserving the on-disk format — but **every document is first renumbered by `src/acl/id_renumber.js`** (`renumberAclIds`): `$id` declarations and `$iref` references are rewritten to a **strictly ascending sequence in text order** using one shared counter per document, with `$blobdoc` values renumbered as independent documents (fresh 1..N) sharing a segment-wide old→new registry (so outer-document `aircraft:` stub entries can `$iref` ids declared inside a byte[] payload). The game's checkpoint reader (JsonDataReader) requires ascending ids in text order; the rebuild's construction-order allocation (wrapper $id 1123 before its inline Aircraft 1120) plus kept low ids after new high ones made the game misbind DockingAircraft inline values → `SetDockingTarget(null)` NullReferenceException per docked jetway at level init. A forward `$iref` (target not yet declared) throws — healthy files never contain one. `$eref`/`$fstrref`/`$guidref` are untouched (different id spaces). `countIdDescents(text)` is the diagnostic (violations/dups/max per scope, blobdoc-aware — it DOES descend into nested `$blobdoc` values; healthy game files score 0). Covered by `tests/integration/id_renumber.test.js`.
- **Flight plans:** Flight-plan entries live in `StaticData.$blobdoc.StaticItems.$rcontent` with `flight-plan:` key prefix. Uses `InitialArrival`/`InitialDeparture` field names. InBlockTime/TakeoffTime always 0 (game computes dynamically). `AirlineName` stores the 3-letter airline code (e.g. `"CDG"`), defaulted to the callsign prefix for new flights. The leg is chosen by the internal `isDeparture` flag (set at creation; the serializer falls back to `OffBlockTime` presence — `_isDepartureFlight()`).
- **Type numbering:** `$blobdoc` sections have **independent type numbering** from the outer document. Type N inside a `$blobdoc` can mean a completely different type than type N outside it. Both save and load paths maintain separate type maps (per segment in the save pipeline).
- **Frontend integration:** Hides InBlockTime/TakeoffTime columns. Skips time-order validation (InBlockTime/TakeoffTime always 0/unset). New flights skip InBlockTime/TakeoffTime defaults.
- **No pre-spawned aircraft:** Files have no WorldState section and no pre-spawned State=30 or State=5 aircraft. Approach/State-5 extraction functions (`extractApproachData`, `extractState5Data`) return empty.
- **Designator mapping — two-pass with jetway fallback:** `buildDesignatorMapping` cross-references `StaticItems` (flight-plan → Registration, AircraftType, Stand) with `RuntimeEntities` in two passes to build the `AircraftType → Designator` map. **Pass A** scans `aircraft:REG` entries for `Specification.Designator` (linked via Registration). **Pass B** scans `jetway:STAND` entries for `DockingAircraft.Specification.Designator` (linked via Stand → AircraftType from the static-item `Stand` field). This handles aircraft whose only runtime representation is inside a jetway's `DockingAircraft` rather than as a standalone `aircraft:REG` entry. Pass B resolves each jetway key through `jetwayKeyStandCandidates` (see below) so a sub-jetway ("it4-A7-1") whose spec is hosted only in its parent's stand form still maps to the flight-plan type. Produces a complete map for spec lookup during save.
- **Runway pairs from static PK entities:** `extractV4RunwayPairs(aclText)` (in `src/acl/utils.js`, re-exported via the parser facade) derives the runway-change pair options for the RunwayEditor from the static runway objects in `PKStaticEntities` — each physical runway ("4L/22R") appears as two `"$k": "runway:*"` entries sharing a `PhysicalName`, yielding the reciprocal pair `4L|22R` + `22R|4L`. This **replaces** the old timeline-change scanning (`collectRunwayPairs`), which found nothing on airports that never defined runway changes (KJFK, KDCA) and hid the runway editor behind `hasPairs=false`. `electron/main.js` calls `extractV4RunwayPairs` on the first ACL in `_collectAirportRunwayPairs` (`collectRunwayPairs` remains only as a fallback if extraction throws). Note the flag-clearing rule in `useEditorSaveActions.jsx`: `timelineModified.runway` must be cleared on save regardless of `runwayTimelinePath` (the ACL patch in `save-acl` always runs; most real levels have an empty `Config.runwayTimelineFile` → no sidecar JSON → previously the flag stayed set and back-navigation falsely reported unsaved changes).
- **Stand alias registration in designator mapping:** the `Stand → AircraftType` map used by Pass B registers **alias forms** of each flight-plan stand so the exact jetway-key lookup resolves — flight plans say `"31"` while jetway keys are `"31A"`/`"31B"`, or `"07"` vs `"7"`. For stands with a numeric prefix, aliases are: the raw string, the un-padded number, the zero-padded (2-digit) number, and each with `A`/`B` suffixes; **first-registered wins per alias** (flight-plan scan order). Non-numeric stands (e.g. `"it4-38"`) register only their exact string — no suffix aliases. Note: a stand's aliases claim its sibling sub-position names too (`"31"` claims `"31A"`+`"31B"`; `"1A"` claims `"1B"`), so when sibling jetways host different aircraft types the first-scanned type wins for the whole family.
- **Jetway-key → stand candidates (`jetwayKeyStandCandidates`, `src/acl/approach.js`, exported via the parser facade):** Jetway keys carry spatial suffixes the flight plans omit — sub-jetways `"it4-A7-1"`/`"it4-A7-2"` belong to parent stand `"it4-A7"`, and numeric stands are referenced as `"31A"`/`"31B"` by jetways but `"31"`/`"07"` by flight plans. The helper returns candidate stand keys in lookup precedence: exact key first, then each trailing `-<digits>` sub-position suffix stripped one at a time (`"t1-3-1"` → `"t1-3"` → `"t1"`); with `includeNumericParse=true` the `parseInt`-normalized form leads the list (`"31A"` → `"31"`; the alias forms still follow for the raw key). Consumers: `buildDesignatorMapping` Pass B (plain form — numeric alias coverage already comes from Pass 1's alias registration) and `_rebuildJetwayEntries` (numeric form first — the departure-stand lookup anchors on the first candidate when nothing matches, matching the game's own empty-jetway behavior).
- **Specification extraction:** `extractSpecificationDB` scans the entire decoded text for `Specification` objects inside jetway `DockingAircraft` entries and `RuntimeData` aircraft entries. A fallback `_extractFallbackSpec(text)` extracts a single spec from arbitrary text when the approachCache specDB/designatorMap are unavailable (intake-side fallback only — emission is strict, see the "STRICT — no fabricated specs" bullet).
- **fmtFloat3 format change:** Both `buildApproachAircraftBlock` and `buildState5AircraftBlock` now emit `float3` values without named fields — `v.x, v.y, v.z` instead of `"x": v.x, "y": v.y, "z": v.z` — matching the actual Odin serialization format. Five empty string-array fields in the `RunwayCoordinator` now share a single `$id` via `$iref` references instead of each getting a unique `$id`.
- **Stand heading from TailPosition→NosePosition:** Stands link to taxiway nodes via `$iref` references (`TailPosition`/`NosePosition`). The `_parseStandPositions` path resolves these `$iref` values to coordinates and computes the heading from the tail→nose vector (`Math.atan2(-dz, dx)`), producing `{ x, y, heading, tailX, tailZ, noseX, noseZ }`.
- **Stand nose/tail-based aircraft positioning:** Both `_buildStandaloneAircraftEntry` and `_buildActiveJetwayEntry` compute aircraft parking position and direction directly from stand nose/tail coordinates instead of using the cached heading or hardcoded defaults. The formula is `direction = normalize(NosePosition - TailPosition)` and `position = NosePosition - WheelBase * direction`, where `WheelBase` is read from the aircraft's `spec` (defaults to 0 if unavailable). In `_rebuildStaticDataSections`, stand positions are parsed once via `_parseStandPositions(text)` upfront and passed as the `standPositions` parameter to all jetway rebuild calls — replacing both the old `atan2(-dz, dx)` heading-based direction (standalone entries) and the hardcoded `(-5.72, 0, -12.54)` / `(0.422, 0, 0.906)` defaults (jetway entries). Jetway position lookups try multiple key formats (numeric parse, raw string, zero-padded) for robustness.
- **Save pipeline cleanup:** After rebuilding StaticData (step 7), `_rebuildStaticDataSections(aclPath, flights, undefined, approachCache, aclcfgStartTime, _saveSec, standPool)` runs a multi-stage cleanup across ALL segments (header blobdoc + checkpoint frames). The caller (`generateFullAcl` in `parser.js`) pre-validates with `_validateStandConflicts()` (consecutive ARR→DEP pairs must have matching registrations — different aircraft cannot occupy the same stand simultaneously) and passes `standPool` = the renderer's `sceneryMaps.standIdToGuid` keys when available (the complete stand set for game-compat relocation). Each GATCARC4 segment is an independent Odin binary document with its own type numbering and `$id` namespace, so the pipeline creates **per-segment type maps** and **per-segment `_IdMapper` instances** for centralized `$iref` remapping. The resolved `saveSec` (from `aclcfgStartTime`, falling back to `resolveConfigTime` — `_saveSec` is ignored) is threaded through to `_rebuildFlightRuntimeEntities` for aircraft position computation. Execution order (critical — 7b MUST run before 7a so jetway detection sees intact `$fstrref` values):
  0. **Game-compat normalization (`_normalizeFlightsForGameCompat(flights, fullText, log, standPool, approachCache)`) — FIRST, before any section navigation:** auto-repairs the four fuzz-discovered init-rejection classes by MUTATING `flights` in place (so the StaticItems rebuild, frame rebuild, and CSV export all see the repaired state; returns `{ renamed, moved, starred }`): (1) **duplicate registrations** (same reg on ARR + DEP → two `flight-plan:` keys + dropped docked DEP aircraft entity) — keeps the frame-linked side's reg (docked side → frame flight-plan direction → departure → first) and renames the other side to `reg-suffix` (loop of unambiguous letters/digits, then `X<0..999>`); (2) **docked-stand occupancy** — a dep at a docked stand occupies it from scenario start until its `TakeoffTime` (falling back to the frame's stored `_departureTakeoffTime`); other-reg arrivals may land there only if the docked dep's takeoff is in-scenario AND the arrival lands after its off-block; (3) **arrival separation** — two arrivals on one stand within `GAME_STAND_MIN_GAP_SEC` (20 min, empirical: game accepts ≥16 min) are rejected (DEP→ARR handovers at any gap are fine — game files ship 2-min handovers; ARR→DEP cross-reg is already gated by `_validateStandConflicts`). Violating arrivals are MOVED to a safe stand from `standPool` (plus stands named by frame jetway keys + stands in use). Docked state is derived from the CURRENT flights (earliest DEP per stand — the same rule `_rebuildJetwayEntries` uses), NOT the frame text, whose docked entries are pre-save/stale. (4) **arrival STAR repair** — the game drops an arrival leg with an empty STAR at `FlightPlan.Init()` ("has neither an arrival nor a departure leg"); every STAR-less arrival gets `Airway` filled with the first STAR of its runway from `approachCache.runwayStarMap` (game-authored arrivals always carry one, e.g. `"SIE.CAMRM5"`), and an arrival on a runway with NO STAR data at all (departure-only runway) is MOVED to the first arrival-capable runway (sorted keys) so the saved plan never ships a runway/STAR combo the scenery doesn't support. The same rule is enforced as a save gate: UI `runTripleValidation` (`val_star_required`, gated on `_starRunwayMap` being non-empty) + server `validateFlightObjects` (`missing_star`, unconditional). Covered by `tests/integration/save_gamecompat.test.js` (8 tests) + `gamecompat-utils.cjs` `arrival-no-star`.
  1. **7a-2. Pre-expand short-form $type refs:** Expands bare `$type: N` references to fully-qualified strings **before** any cleanup steps that might remove type declarations. Uses per-segment type maps so each segment's own declarations are used. Bare refs surviving in KEPT entries are re-expanded in 7b-2.
  2. **7a-3. Pre-compute flight-plan `$id` map (`fpIdByReg`):** Scans each GATCARC4 segment for its highest existing `$id` value — inflated to `entryId + 38` for `jetway:` entries (a conservative ceiling covering every rebuilt jetway's sub-object range; all rebuilt sub-object `$id`s are now allocated from the segment's dynamic allocator seeded above this max) — then assigns flight-plan IDs at `maxId + 1 + i` for each registration (sorted). This `fpIdByReg` map is shared by both jetway rebuild (7b-1) and flight-runtime rebuild (7b-3) so they use identical `$id` values for `_flightPlan` entries. Jetway inline `_flightPlan` (emitted first in `$rcontent`) gets `$id: fpId`; the top-level `flight-plan:REG` entry (middle) becomes `{ __iref: fpId }` pointing back to it; standalone aircraft (bottom) also use `_flightPlan: { __iref: fpId }`. All `$iref` refs are backward, so `_reorderIrefEntries` processes them with zero deferrals.
  3. **7b-1. Rebuild jetway entries constructively (`_rebuildJetwayEntries`):** Replaces the modification-based `_resetFrameJetwayDockingState` with a **constructive** two-pass approach. Pass 1 parses all RuntimeEntities entries structurally (tokenizer), building a stand→flight lookup from DEP flights (when multiple DEPs share a stand, keeps the earliest OffBlockTime). Also builds a stand→arrivals lookup for **turnaround detection**: when a stand hosts both an ARR and DEP for the same aircraft (matching registration) and the arrival lands before the departure off-block, the jetway is left empty (aircraft hasn't arrived at the gate yet). For DEP flights on matching stands, `_buildActiveJetwayEntry()` generates a complete ~35-field Aircraft structure inside DockingAircraft from a hardcoded template. For stale/empty jetways, a clean cleared-state template is used. Claims shared canonical empty-array `$id`s — the six `RunwayCoordinator` `string[]` fields, `AircraftEvent[]` for `_receivedEvents`, `ECommand[]` for `_waitingForCommands` — from a per-segment dynamic allocator: the first aircraft entry that emits one defines it inline, every later entry `$iref`s it. Ids come from the shared allocator (seeded past the segment's max static `$id` incl. rebuilt-jetway expansion and past `fpIdByReg` ids), NOT from entryId+offset ranges — those overlap between adjacent jetways (entryIds 8 apart, template offsets up to 38: jetway:01 `id(33)=36` equals jetway:02 `id(25)=36`) and a duplicate `$id` that is an `$iref` target crashes the game's JsonDataReader with a NullReferenceException.
  3. **7b-2. Re-expand bare $type refs (belt-and-braces):** Re-runs `_expandShortFormTypes` on segments modified by 7b-1. Since the DockingDoorIndex fix, rebuilt jetway entries inject **full-form self-declaring** `"$type": "N|Name"` values (resolved by canonical name against the segment scope) — no bare integers 3-6 are emitted. The pass survives as a safety net for bare refs remaining in KEPT entries, expanded against the 7a-2 pre-modification typeMap.
  4. **7b-3. Rebuild flight-plan, aircraft, and animator RuntimeEntities (`_rebuildFlightRuntimeEntities`):** Deletes ALL existing `flight-plan:REG`, `aircraft:REG`, and `aircraft-animator:aircraft:REG` entries and rebuilds them from editor state in 4 passes — now using **structured JS objects** + `serializeUnityJson()` instead of template-literal string concatenation. Pass 1: remove old entries (stored as raw text to avoid lossy roundtrip), collect old `$id` for iref remapping. Pass 2: rebuild `flight-plan:REG` entries as structured objects with `$type`, `__v` for DateTime ticks, `__fstrref` for static-item references. Jetway-mapped flights (reg has a jetway entry) get `$v: { __iref: fpId }` instead of a full flight-plan object. Pass 3: build `aircraft:REG` entries via `_buildStandaloneAircraftEntry` using structured objects (spec, dynamics data, runway coordinator, position/direction — all JS objects with sentinel properties). Pass 4: build `aircraft-animator:aircraft:REG` entries. **No more hardcoded ordering:** all entries, including kept entries (raw text preserved), are wrapped in `OdinEntry` objects and passed through `_reorderIrefEntries` for topological sort by `$iref` dependency. Entries with no unresolved `$iref` targets emit in input order; entries waiting on a forward `$id` defer until it appears.
  5. **7c. Remove orphaned RuntimeEntities (`_removeOrphanedFlightEntities`):** Removes/renames entries whose `$k` key (`flight-plan:REG`, `aircraft:REG`, `aircraft-animator:aircraft:REG`) references a registration that no longer exists in the rebuilt StaticItems. On registration rename, updates the `$k` value using the renameMap. Handles fallback via `StaticItem.$fstrref` for already-corrupted saves. Updates `$rlength` counters. Safe because aircraft `$id` namespace lives in jetway `DockingAircraft` (preserved by 7b-1), not in the orphaned `aircraft:REG` entries.
  6. **7d. Cleanup EventLog LatestEvents (`_cleanupEventLogLatestEvents`):** On every save, **fully clears** the `LatestEvents` dictionary inside `singleton:event-log.$v.LatestEvents` — sets `$rlength` to 0 and clears `$rcontent` to `[]`. This replaces the old selective-key-removal approach (which tracked `validRegs` and `renameMap`) with a full wipe, preventing stale `aircraft:REG` entries from accumulating across saves. Invisible to step 7c because it lives deeper inside `singleton:event-log.$v.LatestEvents.$rcontent`.
  7. **Centralized $iref remapping (`_IdMapper.remapIrefs`):** After all rebuild steps, applies old→new `$id` mappings collected by per-segment `_IdMapper` instances. Corrects `$iref` references in preserved entries that pointed to removed/rebuilt objects (e.g. flight-plan `$id` values that changed during 7b-3). The `_IdMapper` class acts as a union-find structure with transitive chain resolution and back-to-front positional replacement.
  8. **7a. $fstrref cleanup & remapping (runs AFTER 7b):** IndexOf-based positional scanning for `$fstrref:"flight-plan:REG"` references — replaces with `null` for deleted registrations, remaps to the new registration for renamed ones. Runs after 7b so the jetway rebuild step can detect stale DockingAircraft via intact `$fstrref` values before they are nulled.
  - **Final safety net — ascending `$id` renumbering (`renumberAclIds`, in `writeAcl`):** after ALL rebuild steps and remapping, every segment document gets its `$id`/`$iref` rewritten to a strictly ascending sequence in text order (see the "Binary container" bullet). This is required even when the rebuild's own allocation was correct: kept entries preserve their original low ids, which the construction-order allocator didn't know about, so the emitted text order can still descend.
  - **Registration renames:** When a flight's CallSign stays the same but its Registration changed, the pipeline detects this by matching old entries' CallSign (extracted from `InitialArrival`/`InitialDeparture`) to new flights' CallSign, building a `renameMap` that feeds into steps 7c/7a to avoid unnecessary data loss.
  - **New constants:** `DEPARTURE_TAXI_SECONDS` and `ARRIVAL_TAXI_SECONDS` in `src/utils/constants/timing.js` provide per-airport overrides for estimated OffBlockTime→Takeoff and LandingTime→InBlockTime durations. Values are **medians measured from production saves** — ZSJN: 464s dep / 372s arr, KJFK: 404s dep / 456s arr, `default` 400s both (fallback for airports without data, e.g. KDCA whose saves are clean starts). Measured via `tests/_debug/extract_aircraft_times.js` (decodes all `.acl` under `Airports/<ICAO>/Levels/`, extracts per-aircraft `_departureTakeoffTime`/`_arrivalInBlockTime` from RuntimeEntities + `OffBlockTime`/`LandingTime` from StaticItems → `aircraft_times_report.tsv`) then `tests/_debug/compute_taxi_constants.js` (per-airport median/avg, prints ready-to-paste constant blocks). Used by `_computeTakeoffTicks` / `_computeArrivalInBlockTicks` — lookup is `TAXI_SECONDS[icao] ?? TAXI_SECONDS.default`. `TAXI_SPEED`, `POSITIVE_TAXI_ACCEL`, `NEGATIVE_TAXI_ACCEL` (also from `timing.js`, re-exported via `constants.js`) supply aircraft dynamics values for `_buildActiveJetwayEntry` and `_buildStandaloneAircraftEntry`. `DYNAMICS_POSITIVE_TAXI_ACCEL` (3) / `DYNAMICS_NEGATIVE_TAXI_ACCEL` (-4) in `src/utils/constants/aviation.js` are the boosted dynamics override, verified against healthy game saves: `DynamicsPositiveTaxiAcceleration`/`DynamicsNegativeTaxiAcceleration` use them for final-approach aircraft (`dynState === 2`, `aircraftState === 5` — these decelerate hard on the approach path) and fall back to the static (1/-2) values otherwise.
  - **`GRACE_TTL` changed to -60:** Aircraft that landed up to 60 seconds before the snapshot time are now filtered out of approach generation (was -10s). Gives more margin for aircraft that recently touched down, preventing them from appearing on approach with near-zero altitude.
  - **Scenario end +30 min grace for flight times:** All three enforcement points — UI `runTripleValidation` (`src/utils/validators.js`), server `validateFlightObjects` (`electron/api-server.js`), and the clock-popover bounds `getTimeValidationBounds` (`src/utils/timeUtils.js`) — accept primary flight times up to `_configEndTime + SCENARIO_END_GRACE_MIN` (30 min, `src/utils/constants/timing.js`); the game allows events past scenario end. The lower bound `_configStartTime` stays strict, and timeline/runway-change bounds are NOT relaxed. `get_airport_info` reports the effective bound as `timeRules.maxTime`.
- **Blobdoc scope-aware $type expansion:** The `_expandShortFormTypes` function was refactored into `_expandWithBlobdocScopes` (recursive scope-aware walker that identifies `$blobdoc` entries, extracts their type declarations, and recurses for nested blobdocs) + `_replaceBareTypeRefs` (single-scope regex expander). Each `$blobdoc` section has independent type numbering, so bare `$type: N` inside a blobdoc must expand using that blobdoc's own type declarations — not the outer document's.
- **GameTime.CurrentDateTime parsing:** `extractCurrentDateTime(aclText)` tries `GameTime.CurrentDateTime` first (snapshot time, parsed from nested ticks), falls back to `MetaData.BaseTime`. Used by `resolveConfigTime` to override config startTime with post-warmup time (rounded UP to the next whole minute via `roundSecondsToMinute(cdt.secSinceMidnight, true)` — see `timeUtils.js`).
- **Structured object entry building:** `_buildStandaloneAircraftEntry`, flight-plan entries, and animator entries now build **structured JS objects** with sentinel conventions (`__v` for bare values, `__iref`/`__fstrref`/`__eref`/`__guidref` for Odin references) rather than joining template-literal text strings. These are serialized via `serializeUnityJson()` which handles indentation, `$type`/`$id` ordering, and reference token emission. This eliminates the fragile string-formatting code that was error-prone to maintain. Type strings are now bare values (e.g. `'7|ContextCross.Aircrafts.Aircraft, GroundATC.Core'`) — `serializeUnityJson` adds the surrounding quotes automatically.
- **`$iref` topological sort (`_reorderIrefEntries` + `OdinEntry` class):** The old hardcoded ordering (flight-plans FIRST → aircraft/animator → kept) is replaced by a topological sort of all RuntimeEntities entries. `OdinEntry` is a lightweight wrapper around a parsed entry with lazy-serialized text, lazily-computed `$id`/`$iref` arrays. `_reorderIrefEntries` walks entries in a single pass: self-contained entries (all `$iref` targets satisfied by their own `$id`s or in `externalIds`) emit immediately; entries with unresolved `$iref` targets are deferred until the declaring `$id` is seen. `externalIds` is pre-seeded from the parent dictionary structure (`comparer`, etc.) so references to entries outside `$rcontent` are never misidentified as forward refs. Circular/missing `$id` references are logged as errors and appended as fallback.
- **Odin recursive-descent parser (`parseOdinValue`, `parseOdinObject`, `parseOdinArray`):** A complete string-aware recursive-descent parser added to `acl_json.js` that handles the full Odin JSON dialect: typed-value objects (`$type` + bare values → `__v`), Odin reference tokens (`$iref:N`, `$fstrref:"..."`, `$eref:N`, `$guidref:GUID` → sentinel objects), nested objects and arrays (recursively parsed), bare values (`null`, `true`, `false`, numbers, strings), and proper `\uXXXX` escape handling. No regex on the parse path for correctness. Used primarily by `_reorderIrefEntries` to extract `$id`/`$iref` metadata from kept entries — the entry's original text is preserved for output. Export through `src/acl/parser.js`.
- **Kept entries preserve raw text:** Entries that pass through the rebuild (jetway, radio-channel, singleton, etc.) are stored as raw Odin text and emitted verbatim. This avoids lossy reconstruction of deeply-nested `ReactiveProperty<Aircraft>` structures where `_fixTypedValues` doesn't recursively handle all edge cases. Only metadata (`$id`/`$iref` values) is extracted via `parseOdinObject` — the text is never modified.
- **Pre-computed `fpIdByReg` — unified flight-plan `$id` across rebuild steps:** Both `_rebuildJetwayEntries` and `_rebuildFlightRuntimeEntities` receive the same pre-computed `fpIdByReg` map, ensuring the jetway inline `_flightPlan`, the top-level `flight-plan:REG` entry, and the standalone aircraft's `_flightPlan` all share one canonical `$id` per registration. This eliminates the old pattern where jetway entries used `entryId + 16` while flight-plan entries used a separate sequential counter — two different `$id` values for the same logical flight plan. The pre-computation scans each segment for `maxId` and assigns IDs at `maxId + 1 + i` for sorted registrations, guaranteeing no collision with existing entries. `nextId` is advanced past the pre-computed flight-plan IDs before aircraft/animator entries are built.
- **Shared empty-array `$iref` dedup re-enabled via dynamic allocator (the old inline-only rule is gone):** The original `_rebuildReceivedEventsInEntry` / `_rebuildWaitingCommandsInEntry` sharing crashed Unity's `JsonDataReader` because aircraft entries referenced `$id` values defined inside jetway entries — forward refs appearing later in `$rcontent`. The reintroduced sharing (`rpSharedArr`/`rpStrArr` in `_buildStandaloneAircraftEntry`, `sharedEmptyArrayInner`/`sharedStrArrInner` in `_buildActiveJetwayEntry`) is safe because canonical `$id`s are claimed from the segment's dynamic allocator BEFORE any entry text is emitted, so they always precede the entries that `$iref` them (backward refs only — zero deferrals in `_reorderIrefEntries`). One canonical empty `AircraftEvent[]` / `ECommand[]` / `string[]` per segment: the first emitter writes the inline definition, every later entry emits a bare `$iref`. Non-empty content (State=5 `_waitingForCommands = [CMD_CONTACT_TOWER]`) is never shared — it stays inline at its own id and does not claim the canonical definition.
- **`fpTypeStr` regex tightened:** The `$type` extraction in `_rebuildStaticDataSections` now only accepts the expanded `"N|TypeName"` form. A bare `"$type": N` would not self-register in the blobdoc scope, causing "unknown type id N" during Odin binary encoding. The default `fpTypeStr` is already the correct expanded form via per-file typeMap lookup.
- **Preprocessor additions:** The preprocessor gained passes for: (0) `_fixOdinReferences` — string-aware transformation of `$iref:N`, `$fstrref:"..."`, `$eref:N`, `$guidref:GUID` → sentinel objects for `JSON.parse`; (1.5) `_fixMissingCommas` — inserts missing commas between properties; `_collectBareValues` now handles `null`/`true`/`false` as bare values and recursively fixes typed values inside nested objects and arrays.
- **SPECIAL_KEYS updated:** Added `__iref`, `__fstrref`, `__eref`, `__guidref` to the set in `src/utils/constants/acl-format.js`.
- **Strict type resolution from segment type maps:** ALL type numbers come from the per-segment type map (`segTypeMap` / `jwTypeMap`) — no hardcoded ids anywhere. `_makeJwTypeResolver` is the single shared resolver: `_buildStandaloneAircraftEntry` and `_rebuildFlightRuntimeEntities` use unquoted mode (bare `N|Name`, the serializer quotes it), `_buildActiveJetwayEntry` the quoted full-form mode. This prevents type-id-reclamation collisions with kept entries already expanded to full form by `_expandShortFormTypes`. **STRICT: a canonical type name missing from the segment's declarations throws `[TYPE-ASSERT]`** with the name and a scope dump — fallback ids are never minted (silent fallbacks masked the DockingDoorIndex bug class; the old `_freshNum` counter is gone). **One intentional exception (2026-08-16, fuzz-verified): the approach-dynamics types are resolved LAZILY / CONDITIONALLY — a departure-heavy scope that legitimately never declares `ApproachDynamicsParams` must not crash the save.** `_buildStandaloneAircraftEntry` gates State=5 emission on `scopeDeclaresApproachDynParams` (a scan of the segment's declared names) — an aircraft that would be State=5 degrades to STAR State=30 when the scope can't represent final approach. `_buildActiveJetwayEntry` removed its previously-dead EAGER `DYN_PARAMS_TYPE` resolve entirely (the jetway path never writes approach dynamics; the old code crashed every departure save on such scopes).
- **No fallback type numbers — throw on missing:** The old `_tn(search) || nextFallbackNum++` pattern (`TYPE_NUM_FALLBACK_START` in `acl-format.js` is now a dead export) is gone entirely. `_rebuildStaticDataSections` resolves blobdoc-scoped types (DateTime, FlightPlanArrivalLeg, FlightPlanDepartureLeg, FlightPlanStaticItem) via `_assertBdTn(search, label)` — 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 throws a `[V4-BUILD]` error with a scope dump when neither declares it. **Lazy exception (2026-08-17):** `FlightPlanArrivalLeg` resolves only when the schedule actually contains arrivals — all-departure schedules (e.g. the PerfBench_MaxParked fixtures) never declare it in the blobdoc scope because the game strips unused types from the type table, and a strict assert would crash every such save. FlightPlanStaticItem moved onto this path (it previously used the `_tn` boundary-aware matcher + fallback numbering). In `approach.js`, the `ns(num, name)` helper was replaced with `resolve(key, fullName)` that throws if the key is missing from `typeNums`. Hardcoded fallback defaults (e.g. `33` for AircraftState) are eliminated entirely — types must be present in the per-file type map or the build fails early with a clear error.
- **Approach-builder type numbers from per-file typeNums (never hardcoded):** `buildApproachAircraftBlock` and `buildState5AircraftBlock` resolve **every** `$type` — including `vec3` (`UnityEngine.Vector3`) and `strArr` (`System.String[]`) — from the per-file `typeNums` map. The old hardcoded `"16|UnityEngine.Vector3, UnityEngine.CoreModule"` / `"8|System.String[], mscorlib"` were wrong for real files (ZSJN_leisure_1 declares Vector3 at 5 and String[] at 10), and BCL ids are not stable across Unity versions. Missing keys throw via `resolve(key, fullName)`.
- **Base-time extraction is `$type`-agnostic:** `_extractBaseDateFromText` matches `"BaseTime": { "$type": <any-id>, ticks }` and WorldState flight times with `"\$type": \d+` — DateTime ids are per-scope, so a hardcoded `$type: 3` silently missed files that declare DateTime elsewhere.
- **`CANONICAL_SCOPE` fixture for builder unit tests:** `tests/integration/_canonical_scope.cjs` exports the ZSJN_leisure_1 frame-scope type declarations (ids 0–51). Unit tests that drive `_buildActiveJetwayEntry`/`_buildStandaloneAircraftEntry` must pass it as `jwTypeMap`/`segTypeMap` — strict resolution asserts `[TYPE-ASSERT]` on any canonical name the scope doesn't declare, so `new Map()` no longer works. Derived via `tests/_debug/dump_frame_scope.cjs`; duplicate names keep last-in-text-order semantics (AircraftEvent[] at 1 and 28 → resolves to 28).
- **Missing DockingPositions hard error:** Both `_buildStandaloneAircraftEntry` and `_buildActiveJetwayEntry` throw an `Error` when `spec.DockingPositions` is missing or empty — instead of silently falling back to a hardcoded default `[{ x: -1.742, y: 2.68, z: 14.75, w: 90 }]`. The error message includes the aircraft type name, identifying the missing type in the approach cache specDB early during save.
- **Spec fields are REQUIRED — no silent defaults (`requireSpecField`):** Generalizes the DockingPositions rule to every spec field. `requireSpecField(log, ctx, field, value, spec, fallback)` (in `src/acl/approach.js`, exported) asserts when a resolved field value is missing — `DEFAULT_*` constants are **message content only, never emitted values**. Applies on intake (`extractSpecificationDB`, `_extractFallbackSpec`) and on emission (`buildApproachAircraftBlock`, `buildState5AircraftBlock`, `_buildStandaloneAircraftEntry`, `_buildActiveJetwayEntry`). The assert message 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.
- **STRICT — no fabricated specs in `_buildActiveJetwayEntry` (2026-08-17):** The former vBlock fallback that silently rebuilt a spec from the original jetway entry's `DockingAircraft` via `_extractFallbackSpec` is **removed**. If a flight's AircraftType has no designatorMap/specDB entry — e.g. KDCA `"BOEING 777-300ER"` has no `RuntimeEntities` in any level, so no designator can be derived — the builder logs `[ACL-ASSERT]` with the full lookup trace and **throws** instead of quietly emitting a partial spec. The `lookupTrace` field of the emitted spec likewise dropped its `vBlock fallback=` segment.
- **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. Both builders used to emit 67 for every aircraft, mislabeling widebodies (B789/A333/B77W) in saved output. Now the emitted value is `spec.AerodromeCode` resolved through the normal spec chain — a spec missing it asserts via `requireSpecField` (refusing fallback 67).
- **Every rebuilt jetway sub-object `$id` from the segment's dynamic allocator:** The old static `entryId + offset` scheme collided across jetways — entryIds are 8 apart but template offsets run up to 38. In fails.acl, `jetway:09 id(15) = 190 + 15 = 205` equals `jetway:12 id(3) = 202 + 3 = 205`; a duplicate `$id` that is an `$iref` target makes the game's JsonDataReader bind the **first** declaration (first-wins), so `aircraft:B-5380A`'s `$iref:205` resolved to a `String[]` wrapper → "Data layout mismatch; skipping past node boundary when exiting array". Now `_buildActiveJetwayEntry` allocates every sub-object id from the segment's dynamic allocator (shared with the canonical-array caches, seeded past all static/flight-plan/canonical ids) and registers each old `entryId+offset` id in the segment `_IdMapper`, so the centralized remap step rewrites `$iref` references in preserved entries. The no-cache/defensive path derives a segment-wide allocator past the segment's max `$id`.
- **Jetway field `$type`s resolved by NAME (`_makeJwTypeResolver`):** Type ids are per-blobdoc and vary between files — `R3.ReactiveProperty<Int32>` is id 6 in ZSJN_leisure_1 but id 29 in ZSJN_taixwayclosed, where hardcoded id 6 means `ContextCross.Aircrafts.Aircraft` (writing id 6 for `DockingDoorIndex` misbinds it). `_makeJwTypeResolver(jwTypeMap, quoted=true)` (exported from `flight_plans.js`) builds a name→id lookup from the segment's declarations and returns full-form `"N|Name"` strings; pass `quoted=false` for the object-serializer path, which expects a bare `N|Name` value. **STRICT: a canonical name missing from the scope's declarations throws `[TYPE-ASSERT]`** naming the missing type and dumping the scope (`Scope declarations (N): ...`) — fallback ids are never minted (the old fresh-id counter masked the DockingDoorIndex bug class). **One resolver instance per segment is shared by ALL rebuilt entries (active + empty paths)**. Top-level RuntimeEntity fields resolve the same way (`JW_TYPE_JETWAY`, `JW_TYPE_RP_SINGLE`, `JW_TYPE_RP_AIRCRAFT`, `JW_TYPE_RP_INT32`). `ApproachDynamicsParams` is deliberately NOT resolved here (a dead eager `DYN_PARAMS_TYPE` var was removed 2026-08-16 — it crashed departure-only saves on scopes lacking the type; the jetway path never emits approach dynamics).
- **State=5 aircraft in standalone entries:** `_buildStandaloneAircraftEntry` now supports State=5 (final approach, DynamicsState=2) aircraft. When `aircraftState === 5`, the entry gets `ApproachDynamicsParams` instead of `FlyApproachDynamicsParams`, with `PathPointList`, `TouchDownPosition`, `ApproachDirection`, and `InitialPosition` fields. State=5 params are resolved from `approachCache.state5ParamsMap` by appKey then runway — throws a descriptive `Error` when no cached entry exists (the old fallback derivation from `AppPointList` was removed; `state5ParamsMap` must always be populated at cache-build time). **Scope gate (2026-08-16):** the `rawTargetDist >= flyLen` State=5 decision also requires `scopeDeclaresApproachDynParams` — when the segment's type table omits `ApproachDynamicsParams` (departure-heavy files), the aircraft stays State=30 instead of throwing `[TYPE-ASSERT]` at save time (found by the fuzz test on ZSJN_peakdeparture: fuzz-added arrivals mid-final crashed a save).
- **State=5 `CMD_CONTACT_TOWER` in waiting commands:** State=5 (final approach) standalone aircraft entries now include `CMD_CONTACT_TOWER` in their `_waitingForCommands` array (`__v: [{ $id: id(25), $type: T.cmdArr, $rcontent: [CMD_CONTACT_TOWER] }]`), giving the arrival an initial "contact tower" command. `selectedRunwayExitIndex` is `0` (was `-1` in earlier versions). State=30 (before IAF) and State=10 (DEP parked) entries keep an empty `$rcontent: []`. (State=5 is only emitted when the scope declares `ApproachDynamicsParams` — see the State=5 bullet.)
- **DEP aircraft in RuntimeEntities:** DEP flights now generate `aircraft:REG` + `aircraft-animator:aircraft:REG` pairs during `_rebuildFlightRuntimeEntities`. Previously only ARR flights got aircraft entries (DEP was assumed covered by jetway `DockingAircraft`). For DEP flights with an active (populated) jetway, the aircraft entry is `$v: { __iref: jetwayAcId }` — pointing into the jetway's inline `DockingAircraft` — avoiding duplication of the large Aircraft object. For DEP flights without a jetway (or on a stale jetway), a standalone Aircraft entry is built with taxi values (speed, acceleration) set to 0 (parked aircraft), `DynamicsParams: null`, `aircraftState: 10` (parked-at-stand), and `gearRatio: 1` (gear down). The `radioChannelId` reference defaults to `null` when no radio-channel entry exists. Standalone DEP aircraft still get a position from `_resolveStandPosition`.
- **FlightPlan unified via `$iref` chain:** The `_flightPlan` field in `_buildStandaloneAircraftEntry` is now always `{ __iref: fpId }` — a thin reference to the flight-plan `$id` rather than an inlined copy of the full FlightPlan object. The canonical FlightPlan data lives in one of two places: (a) inside the jetway entry's inline `DockingAircraft._flightPlan` (jetway-mapped flights), or (b) in the top-level `flight-plan:REG` entry (standalone flights). The `fpId` is pre-computed in step 7a-3 and shared across both rebuild steps, so the `$iref` chain is always valid. All refs are backward (jetway emitted first, then flight-plan entries, then aircraft entries), guaranteeing zero deferrals in `_reorderIrefEntries`.
- **Combined aircraft+animator pair building (PASS 3&4 merged):** PASS 3 (aircraft:REG) and PASS 4 (animator:aircraft:REG) are combined into a single loop producing `acPairs` — each element is a `[aircraftObj, animatorObj]` tuple. This guarantees aircraft and animator entries for the same registration are always adjacent in the output, preventing forward `$iref` issues when the $rcontent order changes. The pair array is flattened into the ordered descriptor list.
- **Partitioned kept entries:** Kept RuntimeEntities entries are now partitioned by `$k` prefix into five buckets: `jetway:` → `keptJw`, `radio-channel*` → `keptRadio`, `singleton:*` → `keptSingleton`, and everything else → `keptOther`. Final assembly order is deterministic: `jetway → radio-channel → flight-plan (new) → singleton → aircraft+animator pairs → other`. All kept entries preserve raw text and only extract `$id`/`$iref` metadata via `parseOdinObject`.
- **`typeNameIndex` in approachCache:** `buildApproachCache` exports `typeNameIndex` (inverted `typeMap`: name→id) and `fileTypeNameIndexes` (per-file name→id Maps). Built by `buildTypeNameIndex(typeMap)` in `approach.js` — first declaration wins per Map.
- **Shared canonical-array caches (`strArrCache`/`recvEventsCache`/`waitingCmdsCache`):** Both `_rebuildJetwayEntries` (7b-1) and `_rebuildFlightRuntimeEntities` (7b-3) receive per-segment cache objects for the shared empty arrays — `string[]` (the six `RunwayCoordinator` fields), `AircraftEvent[]` (`_receivedEvents`), `ECommand[]` (`_waitingForCommands`). All three caches share ONE dynamic `$id` allocator per segment (`{ v }` seeded at `segMaxId + 1`), so their canonical ids can never collide with each other, with static entry ids, or with precomputed flight-plan ids. `canonicalId` (claimed once, by whichever of 7b-1/7b-3 runs first) plus `canonicalEmitted` (whether the inline definition has been written yet) handle the cross-ordering between the two rebuild steps. 7b-3 also advances `nextId` past every claim so aircraft/animator ranges never reach them.
- **Radio-channel type resolution from StaticItems:** During `_rebuildFlightRuntimeEntities`, the code parses `StaticItems.$rcontent` via the tokenizer to build a `radioChannelTypeMap` (key → Type: 2=GND, 3=TWR, 5=APP). Kept radio-channel entries in `RuntimeEntities` are matched by key against this map to identify separate `towerChannelId` (Type 3) and `apprChannelId` (Type 5) — replacing the old single `radioChannelId` which grabbed the first radio entry regardless of type.
- **State-dependent radio channel selection:** `_buildStandaloneAircraftEntry` selects between tower (State=5, final approach) and approach (State=30, before IAF) based on aircraft state. State=10 (DEP parked) stays null. Both `_radioChannel` and `_jurisdictionRadioChannel` are set to the same active channel (previously both were always `null`).
- **gearRatio in animator blocks:** `buildAnimatorBlock` accepts a `gearRatio` parameter (default 1). Gear is down (1) for parked (State=10) and final-approach (State=5) aircraft; gear is up (0) for STAR approach (State=30, before IAF) since landing gear retracts during cruise/descent phase.
- **TouchDownPoint $iref resolution:** `resolveApproachProcedureData` resolves touchdown position from the runway entry's dedicated `TouchDownPoint $iref` pointer instead of extending past the last approach-procedure point by a fixed distance. The `$iref` targets a taxiway-node entity at the landing threshold. Throws on missing or unresolvable `TouchDownPoint`.
- **routeName in approach procedure data:** `resolveApproachProcedureData` extracts and returns the `Name` field from the approach procedure's route entry as `routeName`. `_buildStandaloneAircraftEntry` uses this `routeName` for the `ApproachRoute` field, replacing the old `state5ParamsMap` lookup that used `procedureName|runway` pipe-delimited keys (removed).
- **State=5 `_route` uses approach procedure name:** In `_buildStandaloneAircraftEntry`, State=5 aircraft (final approach) set the `_route` field to `state5Params.routeName` (e.g. `"RNAV ILS Z Rwy 19"`) instead of the STAR name (e.g. `"UBSS6W"`). Non-State-5 entries keep `star`; the guard is `aircraftState === 5 && state5Params && state5Params.routeName`. The jetway DockingAircraft template is unaffected (jetway aircraft are always State=10 parked).
- **hintPosition for approach variant selection:** `computeFullTerminalPath` passes the last FlyApproach point (IAF) as `hintPosition` to `resolveApproachProcedureData`. When multiple Type=1 approach procedure variants exist for a runway, the closest one to the hint position is selected instead of the first variant. Without this, TAT could be misestimated and State=5 aircraft positions could be incorrect when variants have different path lengths.
- **totalLen includes touchdown distance:** `_buildStandaloneAircraftEntry` includes the touchdown distance in `totalLen` (the denominator for `rawTargetDist` IAF boundary). This ensures `totalLen` matches the path length that TAT was calibrated for — scenery-derived TAT includes `tdDist` from `computeFullTerminalPath`. Without this, `rawTargetDist` used a shorter denominator, biasing the IAF boundary toward State=30 for aircraft near the runway.
- **Hardcoded InitialPosition.Y:** `_buildStandaloneAircraftEntry` hardcodes `InitialPosition.Y` to `15.24` (the standard ILS approach ceiling at 100 m/unit scale = 5000 ft) instead of using `state5Params.initialPosition.y || 0`. The stored path points have Y=0 (game engine computes altitude internally from touchDownPosition + path distance), but `InitialPosition` stores the approach ceiling altitude directly. Every original game file uses 15.24 regardless of airport.
- **touchDownPosition from state5ParamsMap:** `_buildStandaloneAircraftEntry` resolves touchdown position from `approachCache.state5ParamsMap` (by appKey then runway) instead of `approachCache.runwayThresholds`. `runwayThresholds` returns an array of two threshold positions, not a single touchdown point — passing it to `computePosition` caused `_vec3Dist` to return NaN (`undefined.x - undefined`), so the touchdown segment was never appended to the path and glideslope Y was wrong.
- **v4 Vector4 array parsing via preprocessUnityJson:** `_extractVector4Array` now uses `preprocessUnityJson` + `JSON.parse` with the `__v` sentinel for v4 files, instead of the old bare-number `_parseBareNumbers` approach. 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.
- **Unused import removed:** `TAT_FALLBACK_EXTEND` removed from `approach.js` imports — the constant was no longer referenced anywhere in the module.
- **Timeline rebuild via MetaData object (`_rebuildV4TimelineSections`):** `_rebuildTimelineSections(aclPath, weather, wind, runway)` parses the `MetaData` section value with the tokenizer, splices rebuilt `WeatherFrames` / `WindFrames` / `RunwayTimeline` sub-sections in place (`keyStart` → `valueEnd`, keeping the `"MetaData"` key intact), and writes back via `writeAcl`. Mirrors the load → modify → serialize pattern. In v4 files these timeline sections live **inside MetaData**, not at top level.
- **Weather/wind frames are level-wide — never window-filtered:** A weather/wind frame governs the level from its time onward, so a single frame outside `[Config.startTime, endTime]` still controls the whole level; shipped levels ship such frames (ZSJN_leisure_1: window 05:39:14–06:15:00 with wind at 05:00:00, weather at 09:00:00, and WeatherFrames spanning 06:00–24:00). Consequently `WeatherEditor.jsx` / `WindEditor.jsx` always render **all** frames (no `getTimelineActiveRange` hiding — previously out-of-window frames showed as "N hidden" and were uneditable), their `TimeCell`s have no min/max bounds, and save validation never checks weather/wind times. Only `RunwayTimeline` is window-bounded by design (auto-pruned on load in `EditorScreen.jsx`, `val_runway_change_bounds` at save). The `.tl-list` CSS grids for weather/wind are `display: contents` grids — header and rows must emit exactly the template's column count (5 for `#weather-list`, 6 for `#wind-list`) or cells cascade into the wrong columns. Frame schema: `WeatherFrame = { Preset: string, Time: "HH:MM:SS" }`, `WindFrame = { Direction: int, Speed: int, Time: "HH:MM:SS" }` (see `references/acl-format.md`).
- **RunwayTimeline type-number conflict fix:** When a file's Timeline is empty, `_metaRunway` falls back to `tlTypeNum + {1,2,3}` for the element type numbers — but those may already be claimed by unrelated types in the outer document (e.g. ZSJN files: type 12 = `System.Byte[], mscorlib` collides with the `tlElemTypeNum = 12` fallback when `tlTypeNum = 11`). The rebuild path scans the full document for used `"$type": "N|"` numbers and bumps each conflicting fallback to the next unused number, emitting the full-form declaration on the first element and bare refs after. Verified on ZSJN: 12/13/14 → 55/56/57.
- **Tokenizer `findKeyAtDepth` string-awareness fix:** `findKeyAtDepth(start, end, key, targetDepth)` now only matches the search key at an opening quote that is OUTSIDE a string (`!inString` at `"`), instead of checking for the key text anywhere at the target depth. Previously, key text appearing inside a string *value* (e.g. a value containing the literal `"FlightPlan"` when searching for `FlightPlan`) could be falsely matched and returned the wrong value range. The key-match check moved into the quote handler before the `inString` toggle; escaped quotes (`\"`) are still skipped by the backslash rule.
- **State=5 ProgressRatio=0:** `_buildStandaloneAircraftEntry` writes constant `ProgressRatio: 0` (`STATE5_OUTPUT_PROGRESS_RATIO`) for State=5 (final approach) aircraft — the game recalculates path-based PR from PathPointList. Position/direction are still computed with the real time-based PR. State=30 entries keep their computed PR. Covered by `tests/integration/state5_output_pr.test.js`.
- **New flights: `isDeparture` flag + `AirlineName` code:** Newly created flights (UI factory `createDefaultFlight`, MCP `create_flights`) now carry `isDeparture` (true for departures) and `AirlineName` = the 3-letter airline code (the game stores codes, e.g. `"CDG"`/`"UAL"`, not display names). All 7 arrival/departure classification sites in `flight_plans.js` (static-items leg, stand-conflict validation, jetway rebuild, turnaround winner, runtime flight-plan/aircraft entries) go through `_isDepartureFlight()` — `isDeparture === true` OR non-empty `OffBlockTime` — so a missing flag can never silently serialize a departure as an arrival again. The serializer also falls back `AirlineName` to the callsign prefix. AirlineCode edits sync `AirlineName` via the cascade (renderer `flightCascade.js` + server `applyCascades`). Covered by `tests/integration/new_departure_save.test.js` (saves flag-less flights, asserts the departure leg + code roundtrip).

## Key Rules for Agents

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