ericpzh

ac27-approach

AC27Approach — the BepInEx 6 IL2CPP plugin for Airport Control 27 that live-patches in-game aircraft (heading + altitude override, STAR→final-approach handoff) through the game's native UDP command channel. Use this skill whenever working in mods/AC27Approach (building, editing the Harmony patches, debugging a patch that isn't sticking in-game), when the editor's FlightPatchCommandBar or send-patch-command bridge is involved, when handling clear_for_appr / update_heading / update_speed / altitude / track frames, or when diagnosing BepInEx/IL2CPP interop issues against the game's Aircraft/Dynamics classes. The plugin's deep documentation lives in its own README — read it before making changes.

ericpzh 6 1 Updated 4w ago
GitHub

Install

npx skillscat add ericpzh/ac27leveleditor/ac27-approach

Install via the SkillsCat registry.

SKILL.md

AC27Approach — Plugin Skill

What This Is

A BepInEx 6 IL2CPP plugin (com.ac27.approach) that live-patches aircraft in Airport Control 27 while the game runs. No overlay, no hotkeys — driven through a UDP command channel the plugin owns on 127.0.0.1:20267 (the AC27 shipping build removed the game's AircraftUdpCommandService). Because that build also removed the telemetry service, the plugin emits the editor's live telemetry stream itself on 127.0.0.1:20266 (10 Hz GATC v2, all radar/strip fields) — see the README's "AC27 shipping build — plugin-owned transport". Four patch commands (plus track diagnostics):

Command What it does
update_heading Heading-only override (decoupled): forces the aircraft's nose to a heading each fixed tick. Position/speed/route stay 100% the game's — the aircraft keeps flying its own route. Speed is never read for control, never written. update_position is a legacy alias whose kts field is ignored
clear_for_appr Hands a STAR (state 30) aircraft onto final approach (state 5). Natural flow: NO path is planted — the game's own ApproachState.Init derives the approach path from the aircraft's own AppPointList (its procedure IAF→threshold) and the game's pre-capture steering heads the aircraft to the IAF. Bounded de-snap: the nose rotates at the frame's rate=N (default 3°/s of GAME time — GameDt()) toward the IAF (the game's own pre-capture aim — zero fight) and releases ONLY on capture (dist < 120 u) or the 36k-tick cap; the AVC target speed RAMPS to the commanded knots while pre-capture at a constant acceleration (the frame's accel=N, default 1.5 kt/s of GAME time — 737-class; SpeedCurrentKts, seeded from the aircraft's actual speed — the crawl fix). _waitingForCommands = [PermitLanding]
altitude Climb/descend-and-maintain override: forces the aircraft's Y only toward a target altitude in feet — X/Z, heading, speed, route stay 100% the game's. Smooth by default (Mathf.MoveTowards at rate ft/min of GAME time — same GameDt() rule as the turn, frozen while paused; default DefaultAltRateFpm = 1000 ft/min). Conversion: 1 GU = 100 m → ft = y × 100/0.3048 (≈ ×328.084; Y = 15.24 GU = 5000 ft). Channels: Aircraft.Position (Y), PositionReactive, HeightFeet (ft readout) + the Aircraft3D.SetWorldPosition view lock (Y only). Orthogonal to a heading override (entries mutate in place); ends via clear_for_appr or a level switch (explicitly reset at level load — README's Level restarts section)
update_speed Fly-speed override (the speed sibling of the heading override): re-points the game's own per-step speed write, never fights from a distance. The game's own acceleration constants ramp the aircraft between speeds; per tick the AVC drive re-asserts the commanded knots (DriveAvcSpeed). The lift to the commanded speed is a constant-ACCELERATION ramp (accel=N, default 1.5 kt/s of GAME time) seeded from the aircraft's actual speed. Orthogonal to heading/altitude; ends via clear_for_appr, a radio-slot flip to tower, a level switch, or a state guard. See the payload-table row and README's update_speed note for the full semantics
track Diagnostics: dumps the aircraft's full params every 1 s (`track

Read the plugin's mods/AC27Approach/README.md before making changes — it records every runtime-verified fact and every failed attempt. (Class inventory dump: mods/docs/aircraft-classes-inventory.md.)

Layout

mods/AC27Approach/
├── Plugin.cs # BepInEx plugin entry, patch application, UDP dispatch
├── Patches.cs # Harmony postfixes/prefixes (Aircraft.Step, UDP service, dynamics)
├── OverrideController.cs # Per-tick heading override + params-replant detection
├── ParamTrace.cs # 1 s parameter trace (track command) + cfa watch lines
├── AC27Approach.csproj # net6.0, LangVersion 10, refs pinned to <GameDir>\BepInEx\{core,interop}
└── README.md # THE deep reference — read before changing anything

Build & Install

dotnet build mods/AC27Approach

The csproj auto-copies AC27Approach.dll to <GameDir>\BepInEx\plugins\ (root, no subfolder). BepInEx 6 IL2CPP loads plugins from BepInEx\plugins\ at startup — fully restart the game (not just a level reload) after dropping/replacing the plugin DLL. bin/ and obj/ are gitignored — never commit build artifacts.

Verify load in <GameDir>\BepInEx\LogOutput.log:

AC27Approach loaded
[AC27Approach] Aircraft.Step (postfix): applied
[AC27Approach] View hijack (Aircraft3D.SetWorldPosition): applied
[AC27Approach] Channel lock (Aircraft.set_Direction): applied
[AC27Approach] Dynamics.RestoreRuntimeData (trace): applied
[AC27Approach] Dynamics.SetCurrentState (trace): applied
[AC27Approach] telemetry: emitter ready — 10 Hz → 127.0.0.1:20266
[AC27Approach] commands: receiver bound 127.0.0.1:20267
[AC27Approach] telemetry: icao=ZSJN records=24 src=mgr24+seq9 total=24 clock=True x1 sends=…

(The UDP Mechanism A/B and LogBadDatagramOnce lines are gone — the AC27 build has no AircraftUdpCommandService to hook; the plugin owns the sockets instead.)

AircraftDynamicsData.DynamicsParams setter is deliberately NOT patched — IL2CPP field accessor, both Harmony backends refuse it, and native C++ writes bypass the managed stub. Re-plant detection is a per-step pointer diff instead (params-replant: <CS> DynamicsParams ← <class>).

Sending Patch Frames

Mechanism B — extended frame 0x00E7 (full command set)

From the editor devtools (sendPatchCommand preload → send-patch-command IPC):

// heading-only override: point the nose at 180° (speed/position untouched)
await electronAPI.sendPatchCommand({ type: 'update_heading', callSign, dx: 0, dy: -1 });
// hand a STAR aircraft onto final approach — bounded de-snap at 3°/s of game time:
// the nose rotates toward the IAF (the game's own pre-capture aim) at the frame's
// rate until capture, then the game's steering flies the final turn
// (rate omitted = plugin's standard-rate default; the composer always sends rate: 3)
await electronAPI.sendPatchCommand({ type: 'clear_for_appr', callSign, rate: 3 });
// same, commanding 200 kt approach speed (kts omitted = the ACL default 240 — always written)
await electronAPI.sendPatchCommand({ type: 'clear_for_appr', callSign, kts: 200 });
// diagnostics: dump the aircraft's full params every 1 s (send again to stop)
await electronAPI.sendPatchCommand({ type: 'track', callSign: 'CSC6918' });
// climb/descend-and-maintain: force the aircraft's Y toward 5000 ft at
// 1000 ft/min of GAME time (rate omitted = the same plugin default; only
// Y is overridden — X/Z, heading, speed and route stay the game's)
await electronAPI.sendPatchCommand({ type: 'altitude', callSign, targetFt: 5000, rate: 1000 });

Heading → (dx, dy) = (sin H, cos H), +Z = north, +X = east (030 → 0.5, 0.8660; 180 → 0, -1; 360 → 0, 1). Zero direction = no heading command (the game's own heading passes through) — it does NOT clear an override (there is no clear path; the override ends via clear_for_appr or a level switch — explicitly reset at level load). Altitude conversion: 1 GU = 100 m → ft = position.y × 100/0.3048 (≈ ×328.084; Y = 15.24 GU = 5000 ft — the editor's FT_PER_GU and the plugin's FeetPerGameUnit are the same constant).

Mechanism A — hijacked SelectAircraft frame (always available)

sendUdpCommand(1, '!5:' + callSign) — the game parses it as a normal select, the plugin intercepts before selection happens, so no aircraft gets selected. Only clear_for_appr (the 12-byte callsign field fits any callsign). Never route ! frames through select-aircraft-in-map (selection side-effects).

Frame contract (both mechanisms)

offset 0 uint32 LE magic 0x43544147 "GATC" (1129595207)
offset 4 ushort version 1
offset 6 ushort commandId 1 (Mechanism A) | 0x00E7 (Mechanism B)
offset 8 payload Mechanism A: 12-byte NUL-padded ASCII callsign
 Mechanism B: ASCII, pipe-delimited, NUL-padded to
 exactly 64 bytes (frame = 72 bytes total)

The 64-byte NUL-padded payload is a hard contract: Mechanism B reads the datagram back from the game's receive buffer after the tick and finds the payload by scanning for the first NUL at offset 8 — variable-length frames get stale buffer bytes appended. The stock game logs one UnknownCommand warning per 0x00E7 frame; the plugin suppresses that exact warning via a LogBadDatagramOnce prefix (other bad-datagram reasons still surface).

Payload table (pipe-delimited ASCII): update_heading|CS|dx|dy[|rate] · clear_for_appr|CS[|kts][|appr][|native=0][|rate=N][|accel=N] (a numeric field is always kts; native=0 skips CommandContinueApproach (absent from the shipping game; reflection-invoked, skip-logged when absent); rate=N — keyed, any position after CS — is the bounded de-snap's °/s of game time for the handoff (drives the pre-capture nose rotation toward the IAF); accel=N — keyed, the speed-lift ramp rate in kt of GAME time per second (omitted = the plugin's 1.5 kt/s default); a bare numeric rate would be misread as the kts approach speed) · update_speed|CS|kts[|accel=N] (kts = raw knots — the 3rd field is ALWAYS kts; the optional 4th field MUST be the keyed accel=N ramp rate in kt/s of GAME time — a bare numeric 4th is REJECTED; omitted = the plugin's 1.5 kt/s default) · altitude|CS|targetFt[|rateFpm] (climb/descend-and-maintain — targetFt in feet, ≤0/NaN rejected; rateFpm UNKEYED ft/min of GAME time, omitted/≤0 = the plugin's 1000 default; only Y overridden) · track|CS.

Editor Integration

  • FlightPatchCommandBar.jsx (Flight Strips window): click/drag-driven composer for approach-channel aircraft (controlSeat=5) — Fly Heading (heading-only update_heading; opens a 001–360 slider defaulted to the aircraft's live telemetry heading; its thumb is a plane knob — MAP_ICON_PATH as a data: SVG -webkit-mask-image, rotated by --hdg), Fly Altitude (altitude|CS|targetFt|rate; opens a 1000-ft slider from 1000 up to max(9000, the rounded current altitude), thumb defaulted to the current altitude rounded to the nearest 1000 — hidden without live telemetry; China Z* airports (editor-side isChinaIcao) switch the slider to 300–2700 m in 300-m steps and convert back to feet before dispatch — targetFt is always feet on the wire), Fly Speed (update_speed|CS|kts; 180–240 kt slider, thumb defaulted to the live speed — hidden without live telemetry; the editor sends kts only — the plugin's default ramp of 1.5 kt/s of GAME time applies; per-command accel=N tuning goes through the devtools frame), Fly Waypoint (a direct-to-fix sibling of the voice parser's "fly direct to X": opens the aircraft's current-approach waypoint list from the cache's per-STAR lists ({ "STAR|runway": [{name, x, z},…] }, collect-values _starWaypoints, ordered entry→IAF, filtered by the same FIX_NAME_RE the approach radar applies), clicking a fix commits it and Send/Add dispatch one update_heading frame at the bearing from the aircraft's LIVE position to the fix — recomputed at Send time, so the wire frame is byte-identical to the voice direct-to command (bearingDegrees), with the standard rate=TURN_RATE_DEG_S); Fly Waypoint overrides Fly Heading (a composed heading is dropped, never sent; the heading option blacks out while a waypoint is pending/chained), or Clear for Approach (clear_for_appr, supersedes the whole composed chain — only its frame goes out; the plugin's cfa dispatch removes the override entry anyway). The panels' Add chains the current command onto the line (each chained item has a × to remove) so follow-ups can be composed — heading + speed + altitude + waypoint coexist in the plugin's per-aircraft Entry; a command type already chained blacks out in the options row (one of each type per line); Send dispatches the whole line IN ORDER, N sequential 0x00E7 frames (awaited one by one), then resets — Send/Cancel keep the strip selected so the next command can be composed for the same aircraft. The knob depends on img-src 'self' data: in index.html's CSP — without it the data: URI is blocked by default-src 'self' and the knob fails silently (fixed; the inline <svg><path> map icons are unaffected — not image fetches). See the ac27-editor skill's map-windows reference.
  • Command-capability gate (the composer + PTT are armed by this, not raw BepInEx alone): the strips window checks all three: BepInEx Debug Mode installed AND the AC27Approach.dll actually deployed under <gameRoot>\BepInEx\plugins AND it matches the latest release. The MD5 version check: check-command-capability computes the local DLL's MD5 (updater.computeFileMd5) and HEADs the Worker route for the remote ETag — the R2 object's ETag is the build's MD5 (single-part upload; verified equal to Get-FileHash), so it's a zero-infra version fingerprint (no .md5 sidecar, no Worker change). Returns { bepInExInstalled, pluginInstalled, pluginUpToDate, pluginVersion, pluginRemoteVersion }; a failed remote fetch (offline) degrades to pluginUpToDate:null — the renderer treats null as OK so a working plugin survives without network. commandCapable = bepInExInstalled && pluginInstalled && pluginUpToDate !== false drives the PTT button, the command bar, and which help-overlay entries show — an outdated plugin (MD5 mismatch) falls back to the Load-DLL install button exactly like a missing one, with a one-shot "update available" note + tooltip (load_dll_outdated*). (hasApproachPlugin resolves via bepinex.approachPluginPath — a recursive, case-insensitive search of the plugins dir, skips symlinked dirs). Checked once on window open — deliberately no focus re-poll (the DLL can't change without a game restart, and toggling Debug Mode takes effect on reopening the window). When the plugin DLL is missing (but Debug Mode is on) a Load DLL button (file-tray icon) installs it download-first, dialog-fallback (the Livery button pattern): it opens a progress overlay that hits download-approach-dll IPC — fetches AC27Approach.dll from the https://ericpzh.rest/ac27approach Cloudflare Worker route (proxies the ac27approach R2 bucket's public AC27Approach.dll, which the release workflow uploads) — then install-approach-dll copies the downloaded file to BepInEx/plugins/AC27Approach.dll. On ANY download failure (offline, 404, timeout) the overlay closes and the flow falls back to load-approach-dll IPC: a file dialog picks a DLL, copied to the same canonical path (PLUGIN_DLL_NAME regardless of source filename; EPERM/EBUSY while the game runs = GAME_RUNNING, shown as a "close the game" notice; Debug Mode off = an explainer popup instead of the download). On success the capability check re-runs so the PTT + composer appear without reopening the window. Note: BepInEx alone (Debug Mode on, no DLL) is NOT enough — the composer/PTT stay closed; that's exactly when the Load DLL button exists. Infra reference: mods/docs/cloudflare-worker-routes.md.
  • electron/main.js send-patch-command builds the 0x00E7 frame (parts joined with |, NUL-padded to 64 B) → sendUdpCommand(0x00E7, field).

Verified Hook Points & Interop Gotchas

These are the runtime-verified hooks. Two obvious candidates are deliberately NOT patched (they apply cleanly at load but crash per-frame at runtime): Execute(in UdpCommand) (in-byref NRE inside the Harmony trampoline) and TryParse(ReadOnlySpan<byte>, out UdpCommand) (ref-struct param → invalid DMD IL on every frame, breaks the game's own parsing).

  • Mechanism A hooks ExecuteSelectAircraft(string) (plain string param). Mechanism B reads the datagram back from svc._receiveBuffer in a FixedTick() postfix, with a Socket.Receive postfix as alternative capture. The content dedup applies to the FixedTick buffer path only (skip the stale re-read per tick); the Socket.Receive path dispatches every real datagram unconditionally and the dedup is cleared at level load (a re-sent identical frame after a level restart is a real command).
  • Interop gotcha — Traverse is a trap: Il2CppInterop stubs expose private fields as public properties with the same name — Traverse.Field("_receiveBuffer") silently no-ops; read svc._receiveBuffer directly.
  • Interop gotcha — interface-wrapped types: DynamicsData.DynamicsParams is typed IDynamicsParams, and Il2CppInterop wraps the interface-typed getter's return in the interface's interop class — GetType().Name is literally IDynamicsParams and is FlyApproachDynamicsParams never matches. Identify the concrete class by native class pointer (Il2CppObjectBase.ObjectClass vs Il2CppClassPointerStore<T>.NativeClassPtr) and re-wrap via Pointer. Same proxy gotcha for IDynamicState (_approachState).
  • The active state machine state owns its own captured copies: ApproachState._runtimeData / _pathPointList (and FlyApproachState._flyApproachPathPointList / _appPointList) are Init-copied at state activation and the game's path-following reads THOSE, not the aircraft's data channel. The handoff does NOT rewrite them — the game's own ApproachState.Init derives the approach path from the aircraft's own AppPointList, so the 6b state check is verify-only (path ok (aircraft's own procedure); PathMismatch compares count + LAST point only; a mismatch means a game flow re-derived the path from other data — logged for the watch read, never force-fixed). The tracer still exposes the state's own list as st=… stPath=… stPr=… plus dataSame=1|0.
  • FlyToApproachCondition gate: the canonical CommandContinueApproach() respects it (the plugin invokes it via reflection and skip-logs when absent); a mid-STAR aircraft (pr ≈ 0.68+) can fail the gate → half-transition (enum flipped, state object not). Fallback (step 6b): bypass via dyn.SetCurrentState(_approachState, own-procedure params) with the dynamics' pre-created ApproachState (nothing minted), re-verify, force-write _currentState as last resort. This gate-bypass is the ONLY remaining path-planting site (ApproachDynamicsParams built from the aircraft's own procedure: InitialPosition = the aircraft's pose, PathPointList = own procedure IAF→threshold). The DynamicsState enum write is moved AFTER the transition attempts (step 6c) — a pre-set enum produces misleading half-transition readbacks.
  • clear_for_appr semantics: only STAR aircraft (EAircraftState.Fly) can be handed off; a heading override is replaced at command time by the bounded de-snap (armed at 6b ONLY when the transition confirmed ApproachState — a still-STAR aircraft is never fought): each tick the nose rotates at the frame's rate=N (default 3°/s, GAME time via GameDt()) toward path[0] — the IAF — which IS the game's own pre-capture steering aim (zero fight; without it the game's easing sweeps the nose at ~35°/s, live CJX2697); and the AVC drive (DriveAvcSpeed, shared with the update_speed override) re-paces the aircraft while pre-capture — writes the Controller targetSpeed FIELD (the game writes the field directly, never SetTargetSpeed), sets the ramp output and its own targetSpeed, and re-asserts the eased surfaces each tick (a SetTargetSpeed-only re-assert does NOT stick: the approach state leaves the stale STAR pace ~2.4 kt in place until capture, so the aircraft otherwise crawls at ~1.24 u/s). The re-pacing is a constant-ACCELERATION ramp — the drive's stored SpeedCurrentKts (seeded from the aircraft's ACTUAL speed at the command — the command-time channel write is seeded too, no 1-tick blip) steps toward the commanded knots at the frame's accel=N (default 1.5 kt/s of GAME time — 737-class, symmetric for decel) and that ONE value is written to every surface each tick (stored state, never a surface re-read — the game eases surfaces back between our ticks). The RescheduleEta per-tick re-assert is REMOVED — the plan-anchor shift cannot change the model's pace (rem/ETA are end-anchored: rem ≈ pace × ETA); its ETA-collapse cascade is what flipped the model into IncreaseDecelerate → the ~144 kt Vapp ease. Release ONLY on capture (dist to path[0] < 120 u — CfaJoinDist) or the 36k-tick cap (CfaDeSnapCap, ~10 min at ×1 — must cover the crawl-speed closure) — no on-aim release: the de-snap holds the IAF bearing for the whole pre-capture cruise; a state guard drops the override immediately when the aircraft leaves Approach state (cfa: <CS> de-snap dropped (state → …)). Release log: cfa: <CS> de-snap released (captured|cap); the game's steering flies the final turn at the IAF from there. Game-time clock: every rate-driven rotation steps by GameDt() = ContextCross.Clock.GameTime.TimeScale × GameTime.FixedDeltaTime (resolved lazily from any aircraft's afm _gameTime, VContainer LifetimeScope scan fallback; logs game-time: clock resolved — Delta … (stubbed — unused), TimeScale …, IsPaused … once, or game-time: clock NOT resolved — fallback dt=… when the scans find nothing) — Time.timeScale reads 1 at any game speed (CES5578 ×10 slow-turn log) and GameTime.Delta is a stubbed getter reading 0.00000 live. Diagnostics: the per-tick de-snap diag (cfa: <CS> de-snap diag: hdg …° aim(IAF) …° game …° Δ …° dist … — Δ = angle between our aim and the game's own steering output, stashed per tick by the channel-lock prefix — the sweep evidence: Δ ≈ 0 through the hold = the zero-fight claim) is COMMENTED OUT since the log audit — see the plugin README's log-audit note to re-enable; after a release the 300-step (5 s) post-release observer still logs the game-owned nose every 10 steps (cfa: <CS> post-release: hdg …° pos (…) rbVel …) — the overshoot window. The approach speed is ALWAYS written (240 or the frame's kts; log cfa: <CS> speed: ts=… tts=… dtts=… fwd=True accel 1/-2) — the kts-only behavior left the channel speed unset and the path-following crawled ~1-4 u/s instead of ~123 (live); radio handoff: BOTH _radioChannel and _jurisdictionRadioChannel are written to the airport's tower channel (RadioChannelManager.GetResolvedChannel(EChannel.Tower) via VContainer, RadioSystem _radioChannelBindings fallback — logs cfa: <CS> radio: radio … → TWR(…) and cfa: <CS> radio: jurisdiction … → TWR(…); resolution failure = logged skip, self-heals on touchdown). Tracer fields: chTs=/chTts=/chDTts=/chFwd= (channel speed — the crawl diagnostic), rbVel=/dynVel=/dynTs=/dynAir= (ACTUAL motion — rigidbody velocity + the Dynamics-level speed fields — channel 240 + rbVel 1.24 = stuck in pre-capture steering), afmAdj=/afmETA= (flight-model SpeedAdjustment/ETA), rc=/jrc= (radio slots — the re-assert detector).
  • Runtime lookups use FindObjectsOfType<Aircraft3D> (by Source.CallSign) and a cached VContainer LifetimeScope scan for AirwayRouteService.
  • Interop drifts to guard against (also recorded in mods/AC27Approach/README.md Notes): GameTime.IsPaused is read reflectively (TryReadIsPaused) because it differs across interop versions (plain bool vs ReadOnlyReactiveProperty<bool>) and the refs can lag the shipping game - a DIRECT call to a member the running game dropped throws a JIT-time MissingMethodException that escapes try/catch and kills every Aircraft.Step; TimeScale (0 while paused) is the operative pause gate; Aircraft.CommandContinueApproach is absent from the shipping game (reflection-invoked, skip-logged when absent); the tower channel resolves only through RadioChannelManager.GetResolvedChannel(EChannel.Tower). Re-sync the vendored lib/BepInEx/{core,interop} refs with sync-refs.ps1 when the game updates.

Debugging a Patch That Doesn't Stick

  1. Always check LogOutput.log first — every dispatch logs [AC27Approach] patch: <type> → <CS> … applied (Mechanism B); every apply logs override: <CS> before hdg …° spd … kt → after hdg …° rate …°/s; cfa events log cfa: … (the per-tick diag lines — de-snap diag, speedcorr, avc/sc-probes, params-replant, diag — are commented out since the log audit — re-enable via the README's log-audit note).
  2. clear_for_appr arms a ~3.3 s per-step watch (20 watch: … lines — 200 steps at 60 TPS, one line per 10 steps) so the post-patch seconds land in the log without the auto-trace stream (the 30 s auto-track is gone — it flooded the log with 30 trace: lines per handoff). track|CS toggles the 1 s full param dump manually.
  3. The AFTER dump shows state + route= + wait=[…]; the per-step watch shows whether the transition stuck at the state-object level (st=FlyApproachState with dyn=Approaching = half-transition). params-replant: lines catch the game re-planting the STAR's params — when you see it, the ACTIVE state owns the channel's params (snapshots each step), not the reverse (commented out since the log audit — re-enable via the README's log-audit note). The state-check verdict line is … — path ok (aircraft's own procedure) / rt ok (verify-only — no rewriting; a PATH MISMATCH (Init derived something else than the aircraft's own procedure?) line is a finding to log, not to force-fix), and the de-snap branch emits cfa: <CS> de-snap armed: rate N°/s toward IAF …, pre-capture speed … kt. Expected healthy sequence (from the README's watch signature): NO flight-model reschedule v2 lines at all (the reschedule is REMOVED — the AVC drive re-paces the model) → stPr = 0.000 during the pre-capture cruise to the IAF (EXPECTED, not a stall) → afmETA ≈ 12 s at the commanded pace → rbVel climbing at the LIFT RAMP (2.4→~7 kt within the watch's first 3.3 s at ×1 — 1.5 kt/s — EXPECTED, NOT a stall; ≈ 123 u/s only after the ~2.6 min lift) → nose rotating ≈ 0.05°/step at ×1 (3°/s de-snap) → de-snap released (captured) around dist ≈ 120 u (~55 s in for the crawl case — the ramp-speed closure, well inside the 36k-tick cap) → the natural final turn at 240 kt, with post-release: hdg ≈ <IAF bearing>° lines (no 21° swing = the capture-only release holds; the game-time: clock resolved line must be present — no clock line = the fallback ran). For the update_speed override the healthy speedcorr shape is: after surfaces ≈ the ramp column (climbing at the ramp rate), measured pace tracks the ramp, ramp N→armed, afmRem/afmEta CONSISTENT (no collapse to ~0 — a collapse + adj IncreaseDecelerate + 144 ease is the old failure signature), avcTgt = the ramp value in before+after. (speedcorr/avc-probe/sc-probe lines are commented out since the log audit — re-enable via the README's log-audit note; the tracer's watch:/track|CS dumps still carry the channel/dynamics speed fields chTs=/dynTs=/dynAir=/rbVel=, but the AVC-drive readbacks avcTgt=/scSpd=/scTgt= and the measured pace are speedcorr-only.)
  4. Before/after frames from the game's own UDP service are logged (the game plays AtcContinueApproach audio on a successful handoff — a quick audible check).

Key Rules

  • Read mods/AC27Approach/README.md before changing the plugin — it is the authoritative record of verified behavior and dead ends. Keep it in sync with any behavioral change (the editor skill's rule 21 applies here too).
  • Never hardcode or mint type ids in the plugin's dynamics work (same policy as the editor): everything is resolved from the game's runtime objects or the Cpp2IL dump — see mods/docs/aircraft-classes-inventory.md.
  • Never commit bin/ / obj/ (gitignored). Commit source + docs/ only.
  • The game must be running: the plugin binds the command socket (127.0.0.1:20267) and emits telemetry (127.0.0.1:20266) while a level is loaded. With no level loaded it emits one hasLevel=0 STOP frame then goes silent, so the editor clears live aircraft (5 s stale timeout).
  • Editor-side frame sends must use sendPatchCommand / the documented frame contract — never craft raw datagrams in renderer code, and never route ! frames through select-aircraft-in-map.