Skip to content

AstraTerra Architecture

Runtime Systems

Astronomy/ contains the pure sky model: latitude mapping, sidereal time, horizontal-coordinate classification, sky projection, astrolabe planning, sky-culture loading, seasonal constellation summaries, and meteor shower activity.

Position and projection are kept apart on purpose. ISkyEphemeris answers where a body is at a world time — constant for a catalog star, an orbit for a planet — and SkyProjection answers where that lands for this observer, identically for every kind of body. RenderedStar and RenderedPlanet are both the shared RenderedBody plus the things only that kind of body has, and anything else that moves is expected to wrap it the same way rather than grow a second projection path. CachedSkyEphemeris keeps the first half off the per-frame budget.

Comets take the third route through the same seam. CometEphemeris satisfies ISkyEphemeris from an authored apparition rather than from an orbit: CometApparitionSchedule turns a world time into a signed phase against the nearest perihelion, and one closeness curve off that phase drives both brightness and tail length, so an apparition builds and fades as one thing. The track is interpolated on the sphere rather than in right ascension, because a comet authored across 0h would otherwise be dragged the long way round the sky. Route (1) of the design sketch was taken deliberately — a near-parabolic orbit would need Barker's equation and would change nothing a player experiences, while an authored file is something a server owner can actually write. Visibility has to be an explicit window gate rather than a faint magnitude: the mod's brightness curve is compressed and never reaches zero, so an absent comet made faint would still draw.

KeplerianOrbit, PlanetEphemeris and WorldEpoch are the planet half of that: orbital elements in, geocentric right ascension and declination out, with WorldEpoch owning the one mapping from the world clock to the real calendar the elements are published against. PlanetRenderModel is an instance rather than a static class because it holds a cached ephemeris per planet.

The convention here is narrower than "no Vintage Story types", which the asset loaders in this folder have never followed. It is: the model is pure, and the loaders that feed it are the only exception. A loader may take ICoreAPI to reach the game's asset system, but it must also expose a pure parse entry point over a string so the shipped asset's shape can be tested without a running game — see MeteorShowerCatalogLoader.Parse. Everything else in this folder takes plain numbers and returns plain numbers, which is what keeps it testable outside the Vintage Story runtime.

Client/Rendering/ owns visual presentation. The AstraTerra starfield renders as 3D billboards around the vanilla sun/moon pass. A Harmony prefix on Vintage Story's night-sky pass selects AstraTerra-only, combined, or vanilla-only rendering without shadowing the game's cubemap assets. MeteorShowerVisualModel converts the pure observed hourly rate into short-lived radiant-relative streaks, and MeteorStreakMeshBuilder batches their tapered sky ribbons for that same pass. Orthographic renderers handle telescope overlay, constellation line overlays, and sextant readouts.

Client/Observation/ owns observation mode state. Telescope behavior uses a small shared state object so item interaction, zoom hooks, and renderers agree on scoped mode and zoom.

Client/SkyLying/ owns the lie-down pose. It mirrors vanilla sit-on-G: a remappable hotkey, a stargaze-down recline that sits the seraph down and lowers it onto its back, a supine stargaze idle it then breathes in, a stargaze-up rise that is the recline backwards and quicker, a first-person snap to zenith pitch, a body-yaw pin so looking around does not spin the seraph, and a Harmony postfix on EntityPlayer.updateEyeHeight so the camera follows the ground. The recline and the rise exist because the engine eases a clip in and out by blending its first keyframe against the pose the seraph is already in: a clip that starts from the finished supine pose plays as a slide into it, feet planted and hips in the air, and merely stopping the idle plays that slide in reverse. The rise is only played when the player stands up deliberately — being stood up by walking or dying hands straight back to the default animations, which the transitions suppress while they run. Every clip is animation version 0, like vanilla's seraph — version 1 turns a bone about its cube's corner rather than its joint, which on this rig pulls raised arms off the shoulder. Third person and the fixed overhead camera keep their pitch. Empty hands go behind the head; a held instrument keeps its own arms, and the recline leaves the arms alone throughout. The clips are generated by tools/build_stargaze_clips.py into assets/astraterra/patches/seraph-stargaze.json and mirrored in SkyLyingAnimation, which injects them during player tesselation for any shape the patch did not reach; a test holds the two to each other. Pose state is a process-wide static and must only be set for the local player.

Items/ is the Vintage Story item entry point. Item classes should start and stop observation/readout state, not own sky calculations.

The astrolabe is the one item with a second interaction: sneak and right click cuts its plate. AstrolabeCalibrationPolicy holds the pure half — how far the observer has strayed from the plate, whether the sky allows a sighting, how far along one is — and AstrolabeCalibrationStore keeps the cut latitude in the itemstack's own attributes, so the plate belongs to the instrument rather than to whoever is holding it. The server is the side that writes it, because the server owns the stack; AstrolabeCalibrationState is only the client's progress bar. Every planner reading is then answered for the plate's latitude, never for the player's live position — that substitution is the whole feature, and a source-level test pins it because a regression to live latitude would look like the instrument simply working. Longitude stays live: it shifts the hour of transit, not where the horizon falls.

Constellations/ owns the client-local journal model, graph merge/split behavior, stable saved IDs, and persistence.

A book carries two journals in two separate item attributes: astraterraJournalJson for drawn figures and astraterraPlanetJson for identified planets. They are kept apart so that writing one cannot disturb the other, and so a book written before planets existed still reads. Only the readable page is shared, rebuilt from both whenever either is written.

Planet names are the observer's, not the catalog's. PlanetCatalog carries Mars for the mod's own use, but nothing shows it: an unrecorded planet reads as PlanetJournal.UnidentifiedDisplayName everywhere, and the real names reach a player only through a prepared book. Anything that displays a planet must resolve its name through the held book's PlanetJournal at the moment of display rather than baking it into a cached model, or swapping books will not rename the sky.

Commands/ owns .stars command behavior and debug formatting.

Config/ owns file-backed mod settings.

Infrastructure/ is kept narrow: asset loading and small support helpers.

Asset Layout

assets/astraterra/
├── data/
├── itemtypes/
├── lang/
├── recipes/grid/
├── shapes/item/
└── textures/

Runtime catalog assets are versioned by filename.

assets/astraterra/data/star_catalog.v1.json : Contains the defined stars. Modifing this file can change which stars you see.

assets/astraterra/data/comets.v1.json : Authored comet apparitions — period and phase in world years, a window, a brightness curve, and a track of keyframes across the apparition. Server owners can add their own; the loader rejects a comet that could never be seen rather than letting it fail silently, because on a body due once every thirteen years "never appears" and "not due yet" look identical.

assets/astraterra/data/deep-sky.v1.json : Contains the defined sky images, in our reserved for nebulas, galaxy and other deep sky objects. We use real astronomy photograhs, but these can also be pre-generated.

assets/astraterra/textures/environment/milky-way.png : The galaxy's own glow, as an equirectangular map in galactic coordinates. Generated by tools/milkywaygen/ from a disc-plus-bulge model rather than adapted from a photograph, so it carries no third-party licence and its shape can be changed by parameter.

Sky-culture files are registered through assets/astraterra/data/sky-cultures.v1.json, with individual culture files under assets/astraterra/data/sky-cultures/.

The Sky-culture allows you to predefined different sets of constalations, which might be useful for Custom Stories, and lore.

Rendering Baseline

AstraTerra follows the reference sky implementation-style sun/moon render pass:

  • patch SystemRenderSunMoon.OnRenderFrame3D,
  • conditionally allow SystemRenderNightSky.OnRenderFrame3D according to StarfieldMode,
  • render star quads as 3D billboards with StandardShader,
  • use close sky placement distance around 40f,
  • disable depth test/culling during the star pass,
  • use additive/glow blending,
  • batch transient meteor ribbons into one updated mesh,
  • keep orthographic rendering for overlays and labels,
  • draw the whole pass with scene fog switched off.

The last one is not cosmetic. The standard shader mixes every fragment towards the scene's fog colour by getFogLevel, and for geometry forty blocks out that mix is fogMin plus a distance term. In vanilla the night fog colour is dark enough for the result to read as a slight dimming; a mod that keeps a bright fog colour after sunset does not tint the sky, it replaces it, and the starfield comes out as a flat sheet of blue with the deep-sky plates as solid rectangles in it. The sky already answers to darkness, moonlight and its own horizon fade for how brightly it draws, so shader.FogDensityIn and shader.FogMinIn are pinned to zero and RgbaFogIn to black. BootstrapSmokeTests.The_Sky_Pass_Is_Drawn_Without_Scene_Fog keeps the scene's fog from finding its way back in.

A mesh that is updated every frame must never change size

Vintage Story sizes a mesh's GPU buffers from the vertex count of the first UploadMesh and never grows them. UpdateMesh writes into whatever was allocated, and it tells the draw call the new index count regardless — so a batch that grew between frames loses its vertices to a rejected buffer write and then draws past the end of its own index buffer.

Both per-frame sky meshes are therefore built at a constant size whatever they are drawing: DeepSkyQuadMeshBuilder from a fixed subdivision count, and MeteorStreakMeshBuilder by padding out to MaximumActiveStreaks with empty, zero-area streak slots. Any new mesh built once per frame and updated in place has to do the same. MeteorStreakMeshBuilderTests.Mesh_Size_Does_Not_Change_With_The_Number_Of_Streaks pins it.

Nothing on the sky is rebuilt per frame

Every batch the sky draws is held to the projection it was built at and carried forward by SkyResidualRotation, which turns the whole celestial sphere rigidly about the pole. The projections themselves refresh on StarRefreshThresholdDeg, a twentieth of a degree, which is a handful of times a second rather than sixty.

The deep-sky plates follow the same rule, and hold one buffer each rather than sharing one. Sharing meant rewriting the same buffer between every pair of draws, and a driver cannot overlap a write to a buffer with the read it is about to serve — forty plates up was forty stalls in a row, which is where the 200 ms frames came from. Held apart, a frame that changes nothing writes nothing.

The plates keep their own cached angle rather than borrowing the stars', because the star path can be switched off, or have nothing above the horizon, on a night when the scope still has photographs to draw.

Sky Sprites

The naked eye and the scoped view use different sprite sets, because the difference between them is physical rather than decorative. Rays are scintillation — air, not optics — so they belong to the eye. A telescope steadies them out: a star collapses to an Airy disc, a bright core inside one faint ring, and a planet is the one thing near enough to open into a resolved disc.

Sprite Naked eye Scoped
Bright star star-rays-12-smooth star-log-ring
Faint star star-dog-crisp star-derivative-cross
Planet bright star sprite star-gaussian-soft

The choice matters far more under the scope than under the eye, and the reason is BuildModelMatrix: billboards are sized in degrees, not screen pixels, so telescope zoom magnifies them. At the naked-eye field of view a star covers about eight pixels and no sprite's shape survives; at the brass telescope's ×0.12 field it covers around sixty-seven, and at the precision telescope's ×0.06 more than a hundred, where the sprite is the picture.

That is also where the scoped sky is drawn on top of the deep-sky plates, which are real photographs. The stars photographed into them are round cores with soft halos, so a rayed sprite beside them reads as a different kind of object. SkyStarSunMoonRendererTests.The_Scoped_Sky_Uses_Its_Own_Sprites keeps the two sets from collapsing back together.

Scoped angular size

The plates are the measuring stick for size as well as shape. They are registered by four right-ascension/declination corners, so their scale is known: the Pleiades plate spans 1.508°, and the stars in it have a full width at half maximum of about 0.0044°. Across the shipped set, plate stars run 0.002° to 0.03°.

An unscaled bright star billboard is 8 px × 0.06 deg/px = 0.48° — twenty-five to a hundred times that, wide enough to cover a third of the Pleiades plate on its own. StarBillboardSizing therefore scales angular size by the field of view when scoped:

  • Stars scale with the field multiplier, holding a constant handful of screen pixels at any magnification. A star is unresolvable — magnify it and it gets brighter, not bigger — and this is what puts it on the same scale as the plates it is drawn over.
  • Planets hold a floor (ScopedPlanetAngularFloor), so they do not fall away with magnification. Wide open a planet is barely larger than its neighbours; wound all the way in it is several times their size and reads as a disc. A planet is the one thing near enough for a telescope to resolve, which is the reason to point one at it.
  • Constellation dots follow the star scale, being overlay marks rather than sky objects: a line stays a fine trail at any zoom.

Anything sized in degrees is magnified by the scope

A fixed angularSizeDeg looks correct at the naked eye and bloats under the telescope by up to sixteen times. New sky billboards need to decide which of the two behaviours above they want, or they will look right in testing and wrong through an eyepiece.

Brightness is intentionally game-readable rather than physically faithful. Magnitude affects relative brightness, but faint visible stars keep a readable floor. Star cores use compact, vanilla-like apparent diameters; only brighter stars receive a restrained outer glow.

Test Rules

  • Keep astronomy math and constellation graph rules out of renderer classes.
  • Use file-based asset validation for committed JSON data.
  • Avoid image-diff tests.
  • Keep test names behavior-oriented.