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.
Install
npx skillscat add ericpzh/ac27leveleditor/ac27-approach Install via the SkillsCat registry.
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 anythingBuild & Install
dotnet build mods/AC27ApproachThe 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-onlyupdate_heading; opens a 001–360 slider defaulted to the aircraft's live telemetry heading; its thumb is a plane knob —MAP_ICON_PATHas adata: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; ChinaZ*airports (editor-sideisChinaIcao) switch the slider to 300–2700 m in 300-m steps and convert back to feet before dispatch —targetFtis 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-commandaccel=Ntuning 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 sameFIX_NAME_REthe approach radar applies), clicking a fix commits it and Send/Add dispatch oneupdate_headingframe 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 standardrate=TURN_RATE_DEG_S);Fly WaypointoverridesFly Heading(a composed heading is dropped, never sent; the heading option blacks out while a waypoint is pending/chained), orClear 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 onimg-src 'self' data:inindex.html's CSP — without it the data: URI is blocked bydefault-src 'self'and the knob fails silently (fixed; the inline<svg><path>map icons are unaffected — not image fetches). See theac27-editorskill'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.dllactually deployed under<gameRoot>\BepInEx\pluginsAND it matches the latest release. The MD5 version check:check-command-capabilitycomputes 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 toGet-FileHash), so it's a zero-infra version fingerprint (no.md5sidecar, no Worker change). Returns{ bepInExInstalled, pluginInstalled, pluginUpToDate, pluginVersion, pluginRemoteVersion }; a failed remote fetch (offline) degrades topluginUpToDate:null— the renderer treatsnullas OK so a working plugin survives without network.commandCapable = bepInExInstalled && pluginInstalled && pluginUpToDate !== falsedrives 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*). (hasApproachPluginresolves viabepinex.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 hitsdownload-approach-dllIPC — fetchesAC27Approach.dllfrom thehttps://ericpzh.rest/ac27approachCloudflare Worker route (proxies theac27approachR2 bucket's publicAC27Approach.dll, which the release workflow uploads) — theninstall-approach-dllcopies the downloaded file toBepInEx/plugins/AC27Approach.dll. On ANY download failure (offline, 404, timeout) the overlay closes and the flow falls back toload-approach-dllIPC: a file dialog picks a DLL, copied to the same canonical path (PLUGIN_DLL_NAMEregardless 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.jssend-patch-commandbuilds 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 fromsvc._receiveBufferin aFixedTick()postfix, with aSocket.Receivepostfix as alternative capture. The content dedup applies to the FixedTick buffer path only (skip the stale re-read per tick); theSocket.Receivepath 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; readsvc._receiveBufferdirectly. - Interop gotcha — interface-wrapped types:
DynamicsData.DynamicsParamsis typedIDynamicsParams, and Il2CppInterop wraps the interface-typed getter's return in the interface's interop class —GetType().Nameis literallyIDynamicsParamsandis FlyApproachDynamicsParamsnever matches. Identify the concrete class by native class pointer (Il2CppObjectBase.ObjectClassvsIl2CppClassPointerStore<T>.NativeClassPtr) and re-wrap viaPointer. Same proxy gotcha forIDynamicState(_approachState). - The active state machine state owns its own captured copies:
ApproachState._runtimeData/_pathPointList(andFlyApproachState._flyApproachPathPointList/_appPointList) areInit-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 ownApproachState.Initderives the approach path from the aircraft's ownAppPointList, so the 6b state check is verify-only (path ok (aircraft's own procedure);PathMismatchcompares 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 asst=… stPath=… stPr=…plusdataSame=1|0. FlyToApproachConditiongate: the canonicalCommandContinueApproach()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 viadyn.SetCurrentState(_approachState, own-procedure params)with the dynamics' pre-createdApproachState(nothing minted), re-verify, force-write_currentStateas last resort. This gate-bypass is the ONLY remaining path-planting site (ApproachDynamicsParamsbuilt from the aircraft's own procedure:InitialPosition= the aircraft's pose,PathPointList= own procedure IAF→threshold). TheDynamicsStateenum write is moved AFTER the transition attempts (step 6c) — a pre-set enum produces misleading half-transition readbacks.clear_for_apprsemantics: 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 confirmedApproachState— a still-STAR aircraft is never fought): each tick the nose rotates at the frame'srate=N(default 3°/s, GAME time viaGameDt()) 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 theupdate_speedoverride) re-paces the aircraft while pre-capture — writes the ControllertargetSpeedFIELD (the game writes the field directly, neverSetTargetSpeed), sets the ramp output and its owntargetSpeed, and re-asserts the eased surfaces each tick (aSetTargetSpeed-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 storedSpeedCurrentKts(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'saccel=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). TheRescheduleEtaper-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 intoIncreaseDecelerate→ 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 leavesApproachstate (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 byGameDt()=ContextCross.Clock.GameTime.TimeScale × GameTime.FixedDeltaTime(resolved lazily from any aircraft's afm_gameTime, VContainerLifetimeScopescan fallback; logsgame-time: clock resolved — Delta … (stubbed — unused), TimeScale …, IsPaused …once, orgame-time: clock NOT resolved — fallback dt=…when the scans find nothing) —Time.timeScalereads 1 at any game speed (CES5578 ×10 slow-turn log) andGameTime.Deltais 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'skts; logcfa: <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_radioChanneland_jurisdictionRadioChannelare written to the airport's tower channel (RadioChannelManager.GetResolvedChannel(EChannel.Tower)via VContainer, RadioSystem_radioChannelBindingsfallback — logscfa: <CS> radio: radio … → TWR(…)andcfa: <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 + theDynamics-level speed fields — channel 240 + rbVel 1.24 = stuck in pre-capture steering),afmAdj=/afmETA=(flight-modelSpeedAdjustment/ETA),rc=/jrc=(radio slots — the re-assert detector).- Runtime lookups use
FindObjectsOfType<Aircraft3D>(bySource.CallSign) and a cached VContainerLifetimeScopescan forAirwayRouteService. - Interop drifts to guard against (also recorded in
mods/AC27Approach/README.mdNotes):GameTime.IsPausedis read reflectively (TryReadIsPaused) because it differs across interop versions (plainboolvsReadOnlyReactiveProperty<bool>) and the refs can lag the shipping game - a DIRECT call to a member the running game dropped throws a JIT-timeMissingMethodExceptionthat escapes try/catch and kills everyAircraft.Step;TimeScale(0 while paused) is the operative pause gate;Aircraft.CommandContinueApproachis absent from the shipping game (reflection-invoked, skip-logged when absent); the tower channel resolves only throughRadioChannelManager.GetResolvedChannel(EChannel.Tower). Re-sync the vendoredlib/BepInEx/{core,interop}refs withsync-refs.ps1when the game updates.
Debugging a Patch That Doesn't Stick
- Always check
LogOutput.logfirst — every dispatch logs[AC27Approach] patch: <type> → <CS> … applied (Mechanism B); every apply logsoverride: <CS> before hdg …° spd … kt → after hdg …° rate …°/s; cfa events logcfa: …(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). clear_for_apprarms a ~3.3 s per-step watch (20watch: …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 30trace:lines per handoff).track|CStoggles the 1 s full param dump manually.- The AFTER dump shows state +
route=+wait=[…]; the per-step watch shows whether the transition stuck at the state-object level (st=FlyApproachStatewithdyn=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; aPATH 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 emitscfa: <CS> de-snap armed: rate N°/s toward IAF …, pre-capture speed … kt. Expected healthy sequence (from the README's watch signature): NOflight-model reschedule v2lines 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 →rbVelclimbing 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, withpost-release: hdg ≈ <IAF bearing>°lines (no 21° swing = the capture-only release holds; thegame-time: clock resolvedline must be present — no clock line = the fallback ran). For theupdate_speedoverride the healthyspeedcorrshape is:aftersurfaces ≈ therampcolumn (climbing at the ramp rate), measuredpacetracks the ramp,rampN→armed,afmRem/afmEtaCONSISTENT (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-probelines are commented out since the log audit — re-enable via the README's log-audit note; the tracer'swatch:/track|CSdumps still carry the channel/dynamics speed fieldschTs=/dynTs=/dynAir=/rbVel=, but the AVC-drive readbacksavcTgt=/scSpd=/scTgt=and the measuredpaceare speedcorr-only.) - Before/after frames from the game's own UDP service are logged (the game plays
AtcContinueApproachaudio on a successful handoff — a quick audible check).
Key Rules
- Read
mods/AC27Approach/README.mdbefore 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 onehasLevel=0STOP 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 throughselect-aircraft-in-map.