Imported from meow-sci/gatOS (
.agents/skills/gatos/SKILL.md). Install upstream withnpx skills add meow-sci/gatOS --skill gatos. Copyright stays with the author.
gatOS /sim programming
gatOS exposes live KSA simulation state as a filesystem. Programs read game data and control
vehicles by reading and writing files under /sim — cat a sensor, echo a value to a control
file and it actuates the game (returning a real Linux errno on failure). The exact same surface is
served over HTTP /v1 and MQTT, so a program can run inside the guest (read /sim
directly) or on the host (HTTP). No custom RPC — the files are the API.
For an AI agent, gatOS also provides a first-class MCP server. It projects the same snapshots
and command pipeline as concise logical JSON resources/tools (world, celestial, vessel, kitten,
runtime, direct controls, and typed same-tick/timed batches), rather than mirroring every /sim
leaf. Its public contract is SPEC_MCP.md. Do not invent a filesystem-path
tool or use /sim/display: that terminal-video stream is deliberately outside MCP v1.
The complete, authoritative catalog is
SPEC_9P_FILESYSTEM.mdat the repo root — every path, format, unit, read/write semantic, command action key, errno, and HTTP route. This skill is the orientation; the SPEC is the reference. When you change the/simsurface, update the SPEC in the same change (it has its own constitution).
Read these as needed
| File | When |
|---|---|
SPEC_9P_FILESYSTEM.md |
the full path/format/units/command catalog — your primary reference |
SPEC_MCP.md |
the MCP v1 logical resources/tools, canonical command envelope, batch semantics, capability preflight, and agent-specific caveats |
coordinate-frames.md |
KSA reference frames (ECL/CCE/CCF/CCI/ENU), surface velocity, the Body→CCI attitude quaternion, orbital math — needed for any flight/orbit work; §8 is the camera's six placement frames |
flight-programs.md |
how to structure a control loop; gating (pause/warp/stale); the gogogo-rs and land-o-matic case studies |
recipes.md |
complete runnable Bun/TS programs — connecting, the teleport task, throttle/ignite, burns, events, timed sequences (§12), camera shots (§13) |
In-repo working references: examples/gogogo-rs (minimal Rust control panel — throttle + ignite),
examples/land-o-matic (full Rust G-FOLD+UPFG landing autopilot), examples/sdk-ts (typed
TypeScript/Bun SDK over both transports).
Mental model in 90 seconds
- Reads are SENSOR files:
cat /sim/<path>→ one value +\n. Formats:G9doubles,0/1flags, space-separatedx y zvectors andx y z wquaternions, verbatim strings, NDJSON streams. The atomic per-vessel docvessels/<id>/telemetryis one self-consistent JSON snapshot — prefer it for control loops. - Writes actuate the game:
ctl/…(per-vessel control — incl.ctl/translate, bang-bang RCS translation by body-axis signs:1 0 0= thrust along the nose, latches until0 0 0; and its siblingctl/rotate, bang-bang RCS torque by body-axis signs:+xroll right,+ypitch up,+zyaw right — full authority needsattitude_mode=manual), per-module files (engines/<n>/active,lights/<n>/on, …), anddebug/…(cheats: teleport/impulse/refuel/warp/switch). A write is line-buffered and actuates on the newline; failures returnEINVAL/EACCES/EBUSY/ETIMEDOUT/… - A write blocks until the game thread executes it — one command per frame. Sequential writes can
therefore never land in the same tick (a formation teleport smears ~100 m per frame gap at orbital
speed). To make N writes execute in the same tick, write them as one group to
/sim/ctl/batch: one<path> <value>line per command, then acommitline (SPEC §3.10). Atomic, in order, one phase per batch, ≤64 commands. ⚠ Since KSA2026.8.22.5348,teleport/impulseare Solver phase, so a batch mixing either with a Frame-phase line (e.g.ctl/throttle) now failsEINVAL; an all-teleport formation batch is still correct. To spread writes over time instead of collapsing them into one tick, commit the same lines — each prefixed with an absolute ms offset — to/sim/ctl/timed_batch; they become a host-owned player under/sim/ctl/schedules/<id>/you can pause/scrub/re-rate/loop/stop (SPEC §3.10). Phase mixing is allowed there. - Two attitude paths: write a named mode to
ctl/attitude_mode(Prograde,Retrograde, …) and the onboard autopilot steers (warp-correct, no math) — or compute a Body→CCI quaternion and writectl/attitude_targetfor a custom direction. Attitude/burn writes are solver-phase (take effect next solver step; run ~1× warp for closed loops) — and since KSA2026.8.22.5348so aredebug/…/teleportanddebug/…/impulse, which moved Frame → Solver (revs 5331/5339). - CCI is the working frame:
position/cci,velocity/cci,attitude/quat,debug/teleport,debug/…/impulse(default frame), andctl/burnΔv are all Celestial-Centered Inertial about the parent body (Z = north pole, X = vernal point, equatorial X–Y plane). Constants come frombodies/<parent>/{mu,radius,rotation_rate}. - Pace in sim time, not wall time: block on
time/alarm(orGET /v1/time/wait); gate ontime/sim_dt==0(paused) andtime/warp>1(warping).
MCP operating loop for an AI agent
MCP is operation-shaped even where a tool uses the shared value/values/token/aux wire slots.
Never fill every optional slot speculatively. Choose the operation, read its parameter descriptions
and capability entry, and send only the slots that operation consumes.
- Call
gatos.get_capabilitiesonce per session and again after a feature/configuration change. - Call
gatos.get_world(detail:"summary"), discover raw ids with a list tool, then read the target. - Check
controllable, current parent, module ordinals, feature gates, and degraded health before acting. - Use the narrowest logical tool. Use
gatos.commandonly as the canonical-action backstop. - Use
gatos.execute_batchfor same-tick commands of one derived phase; usegatos.schedule_batchfor absolute millisecond offsets that may mix phases. - Save the pre-action
snapshot_sequence, callgatos.wait(after_sequence:...), and re-read the affected state. An accepted command is not immediate read-back. - Branch on structured
errnoandretryable. Never blindly retry stage, ignite, undock, decoupler fire, release, remove, clear, or another one-shot/lifecycle trigger.
The public operation-by-operation calls and recovery playbooks live under site/src/content/docs/mcp/;
the versioned contract is SPEC_MCP.md.
Maintaining the MCP projection
gatOS deliberately uses the official C# SDK's low-level primitives behind a custom, stateless,
POST-only Streamable HTTP host. Preserve its Host/Origin validation, lack of sessions/GET/SSE, request
limits, cancellation, and real-SDK client tests. Do not add a second command model: logical tools must
map to SimCommand and immutable reads must project SimSnapshot/the shared stores.
An MCP surface change updates together: gatOS.Mcp/ and tests, CommandCatalog where the shared
action changes, SPEC_MCP.md, site/src/data/mcp-reference.ts, the affected site/.../mcp/ prose,
and this skill when agent behavior changes. Multipurpose tool docs must collocate each operation's
exact required fields, enum/range/unit, full example, phase/gate, and retry hazard.
Access at a glance
# In the guest (the /sim mount):
cat /sim/vessels/active/telemetry # read the atomic vessel doc
echo 0.5 > /sim/vessels/active/ctl/throttle # actuate (returns errno on failure)
echo "6578100 0 0 0 7784 0" > /sim/debug/vessels/Hunter/teleport # CCI state vector
echo "10 0 0 body dv" > /sim/debug/vessels/Hunter/impulse # +10 m/s kick off the nose
# On the host (HTTP /v1, default 127.0.0.1:4242; guest: $GATOS_HTTP):
curl http://127.0.0.1:4242/v1/vessels/active/telemetry
curl -X POST http://127.0.0.1:4242/v1/command \
-H 'content-type: application/json' \
-d '{"vessel_id":"Hunter","action":"debug.teleport","values":[6578100,0,0,0,7784,0]}'
The generic write is POST /v1/command with {vessel_id, action, ordinal?, value?, values?, token?}
— see the action-key catalog in SPEC §5. Its file twin is
POST /v1/fs/<path> with the raw value; both mirror the /sim files leaf-for-leaf.
Where things live (quick index of /sim)
time/{ut,warp,sim_dt,warp_speeds,auto_warp,alarm}
system/{name,home,sun}
bodies/<id>/{id,class,parent,children,mass,radius,mu,soi,rotation_rate,
position/ecl, velocity/ecl, orbit/*, atmosphere/*, ocean/*, focus}
vessels/active/… (alias of the controlled vessel) vessels/by-id/<id>/
id name situation parent controlled controllable com scale always_render telemetry stream
position/{cci,ecl,lat,lon} velocity/{orbital,surface,inertial,cci}
attitude/{quat,rates} altitude/{barometric,radar} mass/{total,dry,propellant}
orbit/* navball/* environment/* battery/* power/*
engines/<n>/* tanks/<r>/* rcs/<n>/* solar/<n>/* generators/<n>/*
lights/<n>/* docking/<n>/* decouplers/<n>/* animations/<n>/* encounters
srb/<n>/{engine,part,substance,grain,grain_shape,segment_count,valid,error,active,
propellant,mass,mass_initial,mass_unburnable,mass_burnable,fraction,
mass_flow,burn_time,burning_area,chamber_pressure,chamber_temp,
exit_pressure,exit_temp,area_ratio,
segments/<m>/{part,substance,grain,mass,mass_initial,mass_unburnable,
fraction,radius,length,volume,burn_depth}}
(solid rocket motors — READ-ONLY. Solid propellant is NOT a
tank, so tanks/ never shows a booster; srb/<n>/fraction and
/burn_time are the booster gauges. Ignite via the engine
surface: srb/<n>/engine names the engines/<n> entry. A lit
solid cannot be throttled or shut down.)
parts/json (the whole part/subpart tree as ONE JSON doc — cat + jq it)
parts/<n>/{instance_id,id,display_name,template,is_root,subpart_count,position,
subparts/<m>/{instance_id,id,display_name,template,position}}
(parts + subparts; welds anchor picker; telemetry_vessel_parts)
ctl/{ignite,shutdown,engine,stage,throttle,lights,rcs,translate,rotate,
attitude_mode,attitude_frame,attitude_target,burn,focus}
events
status/{game_version,sampler,accessors,transports}
ctl/batch (atomic same-tick command groups — SPEC §3.10)
ctl/timed_batch (the same control surface on a clock; schedule_enabled=true)
ctl/schedules/{count,clear,help,
<id>/{kind,group,state,t,duration,pending,dropped,clock,last_error,
pause,scrub,rate,loop,stop,remove}} (the live-player registry)
camera/{status,info,target,playback,last_error,enabled,release,mode,follow,tidal,
map/scope, track/<name>, play,set,stop,
pose/{position,frame,anchor,geo,orbit/{radius,azimuth,elevation},rotation,
aim,aim_target,aim_offset,aim_frame,aim_up,roll,fov,ortho,ortho_height,
smoothing,reset}} (programmable cinematic camera; camera_enabled=true)
audio/{file/<name>,play,set,stop,status,info} (userland audio; audio_enabled=true)
debug/ (only when debug_namespace=true)
vessels/<id>/{teleport,impulse,refill_fuel,refill_battery,docking/<n>/pushoff_impulse,
weld,weld_here,unweld}
welds/{clear,count,<source>/{target,part,offset,rotation,lock_rotation,enabled}}
thug_life/{add,clear,count,<id>/{vessel,part,position,rotation,size,visible,remove,spec}}
iva/{enabled,run_outside_iva,adopt,adopt_all,clear,count,stats,interior,help,
<id>/{vessel,part,name,template,position,velocity,angular_velocity,mass,shape,size,
asleep,nudge,release,spec}} (OFF by default — see below)
always_render_iva time/warp focus control_vessel
engineplume/{help,templates/<id>/{core,absorption,emission,mach_diamonds,noise,quality}/*,json,reset}
plumetrail/{help,render/*,json,clear,reset} (one GLOBAL trail renderer)
clouds/{help,bodies/<body>/{shared/*,layers/<n>/{*,two_d/*,raymarch/*,types/<m>/*},json,reset}}
terrain/{help,wireframe,bodies/<body>/{min_height,max_height,slope_roughness_deg,hapke_albedo,
biomes/*,tessellation/*,json,reset}}
Each leaf's format, units, archetype (read-only vs control vs trigger), and backing command action
are in SPEC_9P_FILESYSTEM.md. Per-module dirs appear only when the
vessel actually has that module; bodies/detail dirs depend on telemetry config gates.
Cheats (/sim/debug, ported from the sibling unscience mod): always_render_iva forces interior
(IVA) meshes to render outside the IVA camera; welds rigidly attach one vessel to another vessel's
part (weld = explicit pose, weld_here = capture the current relative pose, unweld; the registry +
clear/enabled live under welds/); thug_life anchors a flat, world-space textured quad (the
"thug life" sunglasses meme) to a part on a vehicle, tracked each frame (gatOS's first custom GPU render —
add/clear + per-entry position/rotation/size/visible/remove). For welds and thug_life,
discover the anchor part from the target's parts/<n>/instance_id (pass 0 to anchor to the body/CoM
frame). A weld anchor may also be a subpart — use parts/<n>/subparts/<m>/instance_id; an
animated subpart anchor (robotics/landing-leg segment) tracks the animation. (thug_life anchors remain
top-level parts only.) Full arg shapes, action keys, and errnos are in SPEC §3.7; a
worked weld_here example is in recipes.md §9 and a thug_life example in
recipes.md §10. The render-side internals (how the quad is drawn into KSA's scene) are
documented in the ksa skill's quad.md.
IVA cabin physics (/sim/debug/iva) — OFF by default: free-floating objects inside a vessel's
interior, with real inertial physics — weightless and drifting while coasting, slammed aft when the
engines light, flung around by RCS rotation, and colliding with the actual interior surfaces.
enabled is a master switch that starts and ends the whole feature: while it is 0 (the shipped
default) no simulation exists at all, and writing 0 puts every object back at its exact rest pose and
tears everything down. Turn it on, then cut props loose:
echo 1 > /sim/debug/iva/enabled
echo "Gemini7 4 Sardine" > /sim/debug/iva/adopt_all # the 4 smallest props matching "Sardine"
echo "Gemini7 12" > /sim/debug/iva/adopt_all # or the 12 smallest loose props, any template
cat /sim/debug/iva/0/position # each object is /sim/debug/iva/<id>/
echo "0.3 0 0" > /sim/debug/iva/0/nudge # flick it across the cabin
echo 1 > /sim/debug/iva/clear # everything back where it was
Objects drive real shipped IVA prop SubParts — so pass a subpart instance_id
(parts/<n>/subparts/<m>/instance_id, or cat parts/json | jq), never a top-level part: KSA saves a
top-level part's transform but not a SubPart's, which is exactly why this can never touch your save
file. Objects park (frozen) under time warp, in the VAB, and outside the IVA camera unless
run_outside_iva=1. Emits iva.impact / iva.escape / iva.release events — wiring iva.impact to
/sim/audio gives you clunks. cat /sim/debug/iva/help is a full readme; the surface, tuning knobs and
errnos are in SPEC §3.7 (iva).
FX editors (/sim/debug/{engineplume,plumetrail,clouds,terrain}): the game's four built-in render
editors as filesystems — one writable leaf per knob, each with a fixed inclusive range (out of range
⇒ EINVAL before it reaches the game), live read-back, a per-entity json document for discovery, and a
per-entity reset that restores the values from before gatOS first wrote them. Scopes differ and this is
the thing to get right: engineplume is per template (an edit repaints every nozzle using that
template), plumetrail is a single global renderer (plus a one-shot clear that deletes existing
trails), clouds is per body → layer → cloud type, terrain is per body (only bodies with a live render
slot) plus a global wireframe. All writes are Frame-phase, cheap enough to animate at 10–60 Hz —
group simultaneous ones through /sim/ctl/batch. Values are stored as 32-bit floats (read-back is
single-precision) and the surface is session-scoped (never persisted; restored at unload). Every field,
range and unit is in SPEC §3.7; cat <family>/help in-guest.
Audio playback (/sim/audio, gated by audio_enabled=true — NOT a debug cheat): play real
audio (mp3/ogg/wav/flac) through the game's speakers. Upload with cat clip.mp3 > /sim/audio/file/clip.mp3 (in-memory, playable once the cat finishes; rm evicts), then
echo 'clip.mp3' > /sim/audio/play — optional key=value tokens start=/end= (ms range),
vol= (0..1), loop=0|1, group=sfx|music|ui (which in-game volume slider governs it),
id= (a handle for later set/stop; auto #N otherwise), pan=, pitch=. Live control:
echo 'bgm vol=0.15' > /sim/audio/set (also pause=1/resume=1/seek=ms); stop with
echo 'bgm' > /sim/audio/stop (or all). cat /sim/audio/status lists live channels;
audio.finished events land on /sim/events (grep -m1 for completion instead of polling).
Keeps playing at any time-warp (deliberate — alarms must not mute). Host-side binary upload:
curl -T clip.mp3 http://127.0.0.1:4242/v1/audio/file/clip.mp3. Full grammar, caps, errnos:
SPEC §3.9.
Timed sequences (/sim/ctl/timed_batch + /sim/ctl/schedules, gated by schedule_enabled=true):
ctl/batch makes N writes land in one tick; a timed batch spreads them over a timeline instead.
Same paths, same values, each line prefixed with an absolute offset in milliseconds (fractional
allowed — never deltas), optional @id/@clock/@rate/@loop/@group directives first, then
commit:
cat > /sim/ctl/timed_batch <<'EOF'
@id launch-seq
@clock render # render | wall | ut
0 vessels/active/ctl/throttle 1
1200 vessels/active/ctl/ignite 1
commit
EOF
cat /sim/ctl/schedules/launch-seq/state # pending|running|paused|done|failed
echo 0.25 > /sim/ctl/schedules/launch-seq/rate # also pause/scrub/loop/stop/remove
The host owns the clock, so timing survives the guest going away and replays identically.
render accumulates the game's clamped frame delta (lags after a hitch, never catches up — right for
footage), wall is true elapsed time, ut is sim time (diverges under warp — right for mission
events). Phase mixing is allowed (unlike ctl/batch). Commit is non-blocking and
all-or-nothing (ENOENT bad path, EINVAL bad value/directive/cap). On catch-up every trigger
fires in order while state controls coalesce to the last write per path (counted at
<id>/dropped) — which is why a densely generated script is cheap. schedule.* events land on
/sim/events. Full grammar + semantics: SPEC §3.10, or
cat /sim/ctl/schedules/help in-guest; worked example in recipes.md §12.
Programmable camera (/sim/camera, gated by camera_enabled=true — NOT a debug cheat): take the
game's main camera and fly it from a shell. echo 1 > /sim/camera/enabled captures the live camera,
parks the game's controller and makes gatOS the only writer; everything you then write is an
override over that snapshot, and echo 0 > enabled (eased) or echo 1 > camera/release (hard cut)
puts it all back. A pose is anchor (pose/anchor: vessel:/body:/part:<vessel>/<iid>/none)
- frame (
pose/frame:ecl|cce|bodyfixed|enu|lvlh|chase) + a placement —pose/position(cartesian),pose/geo(lat lon alt [body:<id>], altitude above terrain), orpose/orbit/{radius,azimuth,elevation}(a non-zero radius wins). Orientation is normallypose/aim <target> [off x y z] [frame <f>] [up world|target|velocity|free] [roll <deg>]— the offset is measured on the subject and re-resolved every frame, which is what glues it to a moving hull.pose/{fov,ortho,ortho_height,roll,smoothing}are the lens. Anchored smoothing filters only the camera-to-anchor component: the anchor's current-frame translation passes through exactly, and anaimtarget remains centred because look-at is an exact constraint rather than a smoothed rotation.
echo 1 > /sim/camera/enabled
echo "vessel:apollo11" > /sim/camera/pose/anchor
echo "bodyfixed" > /sim/camera/pose/frame # +X nose, +Y right, -Z up (so "up" is negative)
echo "-40 0 -6" > /sim/camera/pose/position
echo "vessel:apollo11 off 0 0 -1.2 up world" > /sim/camera/pose/aim
echo 0.35 > /sim/camera/pose/smoothing # critically-damped filter, seconds
cat /sim/camera/status # pose channels + applied_position_ecl/applied_rotation
echo 0 > /sim/camera/enabled # eased hand-back
Three layers composite per channel — track ?? your override ?? the ownership baseline — so a
timed_batch (or a bare echo) can pull focus while a JSON track interpolates position. A track
(cp shot.json /sim/camera/track/shot; echo shot > /sim/camera/play) registers as a player at
/sim/ctl/schedules/camera/, so it needs schedule_enabled too and answers the same
pause/scrub/rate/loop/stop. Every track channel has a pose/ leaf, so the JSON is never
the only route. Gotchas: mode/follow/tidal and the player's camera keys are inert while gatOS
owns the camera (use pose/anchor + pose/aim_target); pose/ortho_height is the one change gatOS
cannot undo; the camera is floored at surface + 0.5 m; an EVA kittenaut walks relative to the shot
while you hold the camera; and iva/map cannot be gatOS ownership contexts at all (only fixed
is — camera/map/scope is the map knob that does ship). Full catalog, errnos and the track JSON
schema: SPEC §3.11; frames in
coordinate-frames.md §8; worked shot in recipes.md §13.
First-class per-vessel nodes (NOT under /sim/debug; also ported from unscience):
vessels/by-id/<id>/scale — write any finite value > 0 to uniformly rescale the whole vessel model
one-shot (echo 50000 > scale = planet-sized; echo 1 > restores the live transform;
0/negative → EINVAL). The live write changes transforms without refreshing physics, unlike the
physical in-game editor gizmo (clamped 0.5×–2×, quantized to 0.25 m). KSA saves top-level part scale:
loading can rescale colliders, mass, tanks and nozzles, including every non-root part since
2026.9.10.5438 (rev 5434). Restore the intended scale before saving a cosmetic experiment.
Staging/undocking need not reset it; subpart and EVA avatar transforms have distinct lifetimes.
vessels/by-id/<id>/always_render — write 1 to keep that vessel rendered at any distance
(bypasses the sub-pixel cull that normally hides far vessels; the mark survives scene rebuilds and
auto-drops when the vessel despawns; EVA kittens are unaffected).
Both work on any vessel by id even with control_all_vessels=false (deliberately
authority-exempt). SPEC §3.4.1 has the full semantics.
Gotchas that cause silent failures
- A vessel's name is its id (
Hunter,Polarisare literal ids). Ids in/simpaths are sanitized (non-[A-Za-z0-9._-]→_). control_enabled=false→ all writesEACCES.control_all_vessels=false→ only the controlled vessel is commandable (camera.focus,vessel.scale,vessel.always_renderanddebug.*stay exempt).debug_namespace=false→/sim/debug/**is gone. All default on./sim/debug/iva/enabledis off by default and is a separate switch fromdebug_namespace— theiva/directory is there, but adopting anything beforeecho 1 > enabledanswersEOPNOTSUPP, and nothing is simulated until you do.adopttakes a subpartinstance_id(a top-level part isENOENTby design) and refuses anything bigger thaniva_max_object_size(0.5 m) so you cannot cut a hull panel loose.debug.teleportsets a CCI state about the vessel's current parent body — the vessel must already be in the intended body's SOI. See SPEC §6. Since KSA2026.8.22.5348bothteleportandimpulseare Solver phase (they land on the next solver step, and cannot share actl/batchwith Frame-phase actions).debug/…/impulsedefaults to newton-seconds (Δv = J ÷ live mass), not m/s — append thedvkeyword to apply the vector directly as Δv, andbodyto read it in the vessel frame (+X = nose):echo "10 0 0 body dv" > impulse. Same CCI-about-current-parent frame as teleport otherwise.ctl/stageis per-module and no longer touches RCS (KSA2026.8.22.5348, rev 5329). It walks the staged part's subtree and activates onlyISequencedmodules — exactly engines and decouplers — whose own sequence matches.ThrusterControlleris not one, sorcs/<n>/activedoes not flip as a side effect any more: toggle RCS explicitly. Engines/decouplers on sub-parts now stage where they used to be skipped, and a part with modules in different sequences needs onectl/stagepress per sequence.engines/<n>/min_throttleon a multi-engine stack inverted (KSA2026.8.22.5348, rev 5317). The write still sets that engine's own floor, but the flight computer now folds the active engines withMax(seed0) instead ofMin(seed1) — the most restrictive engine sets the effective floor, not the least. One high value now raises the whole stack's floor.navball/deltavandnavball/twrwere corrected in KSA2026.8.22.5348(rev 5318) — a part in sequence 0 used to zero both. Same path, format and units; re-baseline stored expectations.ctl/burntiming moved (KSA2026.8.22.5348, rev 5317) — same interface and CCI Δv, but burn duration, throttle profile and the point at which the FC abandons an auto burn all differ from earlier builds. Don't hard-code a burn duration.debug/terrain/.../tessellation/range_mre-baselined (KSA2026.8.22.5348) — the engine default moved 220 → 50 m and the shader's displacement falloff moved fromrange×0.1…0.95torange×0.75…0.975. Read the live value before writing; old tunings look wrong.debug/plumetrail/render/trail_coloris gone (KSA2026.9.7.5402). The renderer's global debug tint was removed — trail colour/density/lifetime are per plume-trail template asset now. Reading or writing it answersENOENT; the other tenrender/*fields are unchanged.parts/<n>/display_nameis the authored template name since KSA2026.9.7.5402("Parachute Bay", "Drogue Radial A"…) and therefore not unique — key oninstance_id, never on the name.- Crashes can spawn debris/fragment vessels (KSA
2026.9.7.5402). Parts now have crash tolerances; a hard contact can split a vessel into<id>_1,<id>_2… fragments plus single-part debris, all of which appear undervessels/(controllable=0, one part, orbit events frozen), and control moves to the largest controllable fragment on its own. Programs that enumeratevessels/should tolerate ids appearing mid-flight; there is nodebrisflag yet. ctl/stagealso arms parachutes and fires cut modules (KSA2026.9.7.5402) — the newParachuteDeploy/ParachuteCutmodules areISequenced, so a stage press behaves exactly like the stage key on a chute bay.animations/<n>/goalon a chute-bay door fights the chute state machine.POST /v1/command/gatos/commandrequirevessel_ideven for globally addressed actions — the wholecamera.*,schedule.*,audio.*families anddebug.warp/debug.thug_life_*/… name no vessel, so send"vessel_id": "". Omitting it (or sendingnull) is400 EINVAL.camera/play/set/stopneed[schedule] schedule_enabledas well ascamera_enabled— a camera track is a/sim/ctl/schedulesplayer (kind = camera-track, idcamera). With scheduling off they answerEOPNOTSUPP; every camera leaf still works.- A malformed camera track fails on
close, which carries no errno — thecplooks like it worked. Read/sim/camera/last_error(or upload over HTTP, where thePOSTdoes carry the EINVAL). - Timeline units are mixed on purpose:
timed_batchoffsets andctl/schedules/<id>/tare milliseconds; a track'st/durationandcamera/play atare seconds. - Mass is kg; gravity is μ/r² (never 9.8); ground-referenced velocity is
v_cci − ω×r. - Don't substitute a generic quaternion library for the Body→CCI attitude math — use KSA's exact
convention (
transform(+X, q) == thrust_direction); seecoordinate-frames.md.
Example: "teleport Hunter to a 120 km Earth orbit, Polaris 50 m ahead"
This is fully worked (a copy-pasteable Bun/TS program) in recipes.md §1. The shape:
read bodies for Earth's mu/mean_radius, compute r = radius + 120000 and v = sqrt(mu/r),
teleport Hunter to CCI [r,0,0, 0,v,0] (equatorial circular), and teleport Polaris to the same orbit
advanced in true anomaly by Δθ = 50/r. Everything you need to derive it is in the SPEC + frames doc.
Paint vehicles and EVA kittens
Paint is session-only and explicitly opt-in. Enable vehicle shader paint with
echo 1 > /sim/paint/parts/enabled, then set a normalized sRGB triple and enable the desired rule.
Whole-vessel example: write 0.15 0.5 1 then 1 under
/sim/vessels/by-id/<id>/paint/parts/{color,enabled}. An adjacent part/subpart paint/ directory is
the individual override; it requires the parts telemetry gate. Precedence is instance, live vessel,
template, global.
Enable safe EVA clones with echo 1 > /sim/paint/kittens/enabled. Shared rules live under
/sim/paint/kittens/{shared,materials}; individual rules under the EVA vessel's
paint/kitten/{default,materials}. Material names are discovered live (body, fur, helmet,
visor, mmu, with numeric suffixes). Disabling either master restores stock but retains rules;
write its clear trigger to remove rules. HTTP and MQTT use the normal field mirrors; MCP uses
gatos.paint_control or canonical gatos.command.
Draw ground clutter with your own images
/sim/paint/textures re-points a stock ground-clutter texture (rocks, trees, grass, shrubs) at an
image you upload. An ordinary sRGB PNG renders as authored — the default faithful bind mode
corrects for the clutter shader, so no pre-processing and no colour maths are asked of the user.
Session-only, in-memory, restored on unbind. There is no master switch — the feature is inert
until something is bound.
The flow is upload → bind → teardown:
cat /sim/paint/textures/clutter # texture-id slot w h mips used_by ecotypes
# texture-id is a content-relative asset PATH, e.g.
# Textures/Planets/Earth/GroundClutter/Grass_Diffuse.ktx2
cat rock.png > /sim/paint/textures/file/rock.png # png|jpeg|bmp|hdr|dds|ktx|ktx2
echo 'Textures/Planets/Earth/GroundClutter/Grass_Diffuse.ktx2 rock.png' > /sim/paint/textures/bind # 'faithful' by default
echo 'Textures/Planets/Earth/GroundClutter/Grass_Diffuse.ktx2 rock.png raw' > /sim/paint/textures/bind # …or byte-for-byte, like a stock texture
cat /sim/paint/textures/applied # <id> <file> pending|applied|failed w h mips vram error
echo 'Textures/Planets/Earth/GroundClutter/Grass_Diffuse.ktx2' > /sim/paint/textures/unbind # one; 'all' = everything
echo 1 > /sim/paint/textures/clear # same global teardown as 'unbind all'; uploads survive
rm /sim/paint/textures/file/rock.png # evict an upload (unbinds it first)
Rules a program must follow:
- A plain sRGB PNG just works. Bind's optional third token is the render mode and defaults to
faithful, which rewrites the decoded pixels so the image renders at its authored colours, in every biome. Nothing has to be pre-corrected, and a user does not have to be told to author against mid-grey. - Discover, never guess, target ids. Read
clutterand use its first column. A<texture-id>is the asset's content-relative path (e.g.Textures/Planets/Earth/GroundClutter/Grass_Diffuse.ktx2) — install-independent, unique per asset and space-free, so it drops straight into the space-separated line. It is not a symbolic name and cannot be constructed by hand. Bind takes<texture-id> <file> [faithful|raw]— the same shape abindingsrow reads back (mode column included), so a listing line can be echoed straight back to re-create a binding. - The write's success is not the GPU's.
bindonly queues a Frame-phase command. Pollapplied(orstatus, which carriesbound applied retiring vram_bytes revision error) for the real outcome; a failed decode leaves the stock texture drawn and puts the reason in the row. - A binding replaces a texture asset.
used_by > 1in theclutterlisting means every one of the listed ecotypes changes together. Check before binding. - Errno vocabulary:
EINVALbad name / unparseable line / unrecognised container;ENOENTunknown texture id or missing upload;EBUSYthe upload has not committed yet;ENOSPCfile, byte, or binding cap;EFBIGper-file cap (mid-write, so the failingwrite(2)carries it). Images larger thanpaint_texture_max_dimensionare downscaled, not rejected. - Uploads over the network are the one non-uniform surface. HTTP uses
PUT /v1/paint/texture/file/<name>[?offset=N&complete=0|1]and answers 413/EFBIG above the 1 MiB request cap, so chunk any real PNG. MCP usesgatos.paint_texture(operation:"upload")with base64 (which inflates 4/3 against the 24 MiB frame). MQTT carries no binary upload, though it mirrors every control leaf. Everything else rides/v1/fs/paint/textures/...andgatos/sim/paint/textures/...normally; MCP binds withgatos.paint_control(operation:"texture_bind", target:"<texture-id>", file:"<name>", value:0|1)—valueis the mode (0=faithful,1=raw). rawis the like-for-like option. Clutter diffuse maps are modulation maps, not albedo: the shader computesalbedo = 2 * decode(t.rgb, t.a) * mix(meanLum, instanceColor, t.a) / meanLum, so the texel is doubled and alpha is not opacity — it selects sRGB (0) vs linear (1) decoding and the blend toward the per-instance terrain tint.faithful(the default) cancels both: RGB is scaled by2^(-1/2.2)(white255→186, round-trip error < 0.2%) and alpha is cleared to0.rawuploads the decoded bytes untouched, so the image is read exactly as one of KSA's own clutter textures — mid-grey0.5neutral, biome-tinted atA=255— which is what you want when replacing a stock texture like-for-like. A decode that is not RGBA8 (some ktx/dds/hdr) cannot be corrected: thefaithfulbinding landsfailedinapplied, its error namingrawas the fix. Real cutout opacity is a separateopacityslot either way. Mips are generated automatically and are mandatory — without them the texture aliases badly at range.
Spray your own images onto the world (stickers)
/sim/paint/stickers projects an uploaded image onto a vehicle part, onto terrain, or onto the
ground clutter standing on it. There is no sticker upload surface — a sticker's image is an
entry of /sim/paint/textures/file/, so upload once and reuse it for as many stickers as you like.
Session-only, no master switch: with nothing placed the feature is one branch per frame.
cat meow.png > /sim/paint/textures/file/meow.png # the shared image store (png|jpeg|bmp|...)
echo 'meow.png w=2 h=2' > /sim/paint/stickers/spray # aim=camera by default; aim=cursor also works
cat /sim/paint/stickers/last # 0 vessel Kitten-1 part 41 hit 8.42m (or: no hit within 2000m)
cat /sim/paint/stickers/status # <id> <image> vessel|body <target> live=0|1 texture=ready|missing|...
cat /sim/paint/stickers/info # enabled= stickers= stickers_max= live= images= vram_bytes= patch= renderer= max_view_distance_m=
echo 0.4 > /sim/paint/stickers/0/alpha # size "w h" | depth | rotation | alpha | brightness | visible | image
cat /sim/paint/stickers/0/spec # meow.png vessel Kitten-1 41 0.1 0.5 -1.4 0 1 0 roll=15 w=2 h=2 d=0.3 alpha=0.4 brightness=1
echo 1 > /sim/paint/stickers/0/remove # one; 'echo 1 > clear' for all
Place by coordinates instead of aiming, when you want it exact and scriptable:
iid=$(cat /sim/vessels/by-id/Kitten-1/parts/0/instance_id) # needs telemetry_vessel_parts=1
echo "meow.png vessel Kitten-1 $iid 0 0.5 -1.4 0 1 0 roll=15 w=0.6 h=0.3" > /sim/paint/stickers/place
echo 'meow.png body Mun 12.03 -41.88 heading=90 w=5 h=5' > /sim/paint/stickers/place
Rules a program must follow:
- Two grammars, one shared tail.
spraytakes<image> [aim=camera|cursor] [range=] [roll=] [w=] [h=] [d=] [alpha=] [brightness=];placetakes<image> vessel <vessel_id> <part_iid> x y z nx ny nz [roll=…]or<image> body <body_id> <lat> <lon> [heading=…].w/h∈(0,1000]m default1;d∈(0,100]m default0.3on a vessel,1on a body;alpha∈[0,1]default1;brightness∈(0,8]default1;range∈(0,1e6]m default2000;roll/headingany finite degrees, default0. The rotation key is anchor-specific —rollfor a vessel,headingfor a body — so neither spelling can be used against the wrong anchor. Duplicate, unknown or out-of-range keys are EINVAL on thewrite(2), before anything reaches the game. spraycan miss. No hit withinrange⇒ ENOENT, andlastreadsno hit within <range>m. It hits vehicle parts first and the terrain behind them; ground clutter cannot be aimed at (it exists only on the GPU) — but the decal box then projects onto the rock anyway, which is the point. Onspray,roll=adds to the orientation that reads upright from where you are, and omittingd=is not the same as passing a number: the default is chosen after the ray says what it hit.- Ids are the smallest free slot and are reused after
remove/clear. Never assume the id of a create — readlast, or wait for thepaint.sticker_placedevent on/sim/events(its detail is the same line, andvessel_idis set only for a vessel anchor). <id>/specis exactly aplaceline. Echo it back to clone the sticker under a new id. That is also the save game:for d in /sim/paint/stickers/[0-9]*; do cat "$d/spec"; done > ~/specs, plus a copy of the images, replayed at boot. Nothing is persisted host-side.- Dormant, not deleted. A despawned vessel, a staged-away part or an evicted image gives
live=0(texture=missing) and the entry survives with its settings andspecintact; it draws again when the anchor or the image comes back. Re-uploading an image under the same name hot-swaps it under every sticker using it. - Sub-parts are valid anchors.
part_iidmay come fromparts/<n>/subparts/<m>/instance_id, andspraynaturally anchors to the sub-part it hit — which is why a decal on a gimballing bell or a robotics segment tracks it. - What the renderer will and will not do. Main viewport only (not crew portraits or extra
windows). Beyond
paint_stickers_max_view_distance_m(5000 m) a sticker is not drawn at all. The decal fades out at grazing angles (normal cutoff) and its lighting is an approximation of the surface it sits on — usebrightness∈(0,8]to compensate rather than expecting exact scene lighting.echo 1 > /sim/paint/stickers/debugdraws every sticker as a magenta checker of its projection box: use it first whenever a sticker is missing or misplaced. - Health, not exit status. A create only queues a Frame-phase command.
infocarriespatch=0|1 renderer=idle|active|degradedandlast_errorcarries any renderer/texture fault; the latches arepaint.sticker_rendererandpaint.sticker_texturein/sim/status/accessors. - Caps and gates:
paint_stickers_max_count(256) — a full registry is EINVAL naming the limit, not ENOSPC. The subtree exists only when bothpaint_stickers_enabledandpaint_textures_enabledare on. Gate stringcontrol_enabled + paint stickers. - Transports. Everything above mirrors to
/v1/fs/paint/stickers/...andgatos/sim/paint/stickers/...normally. MCP uses one tool,gatos.paint_sticker(operation:"place"|"spray"|"set"|"remove"|"clear"|"list"|"debug")—settakesidplus exactly one knob (width+height,depth,roll/heading,alpha,brightness,image, orvaluefor visibility) — plus thepaint_stickersruntime feature document. Images still upload throughgatos.paint_texture.