# THE DESCENT — Unreal port: Design (Designer → Coder) Single source of truth for the Unreal Engine 5.8.1 port. Requirements come from `BRIEF_UE.md` (U1–U14, plus V1–V13 from `the-descent/docs/BRIEF_V2.md`). Where this document and the brief disagree, **the brief wins**. Where this document and the code disagree, **this document wins** until it is amended. The browser version (`the-descent/src/*.js`, `the-descent/docs/DESIGN_V2.md`) is the reference implementation. Numbers marked *(v2)* are ported unchanged because they worked. Numbers marked *(new)* replace or add to v2. Contents: 0 Conventions · 1 Architecture · 2 Terrain and world · 3 Carrier · 4 Tender · 5 Player · 6 Systems · 7 Look · 8 Sonar, map, horror, audio, UI · 9 Verification harness · 10 Milestones · 11 Risks and open questions. --- ## 0. Conventions (read before anything else) | Topic | Rule | |---|---| | Units | The design is in **metres, seconds, degrees**. 1 m = 100 Unreal units (uu). Convert only through `Descent::MetresToUU / UUToMetres` (`Source/TheDescent/DescentUnits.h`, exists). Every number in this document is metres unless it says uu, cm or px. | | World axes | Unreal axes: **+X north ("down-canyon"), +Y east, +Z up**. Altitude is Z in metres. | | Vessel axes | Carrier, Tender and every placed object: **+X bow / nose, +Y starboard (right), +Z up**. Port is −Y. | | Heading ψ | Unreal yaw: 0° = +X, positive toward +Y (clockwise seen from above). Pitch positive = nose up. | | Depth | `Depth = DATUM − Z` with **`DATUM = 1780 m`** *(v2)*. One function, `Descent::DepthOfZ(double Zm)`, is the only place that computes depth. The rim of the Throat is Z = +40 (depth 1,740 m). | | v2 → UE coordinates | v2 was right-handed, y up, bow toward −z. Map every v2 vector with **(x, y, z)₍v2₎ → (X = −z, Y = x, Z = y)**. All tables below are already converted. | | Kazah (Blender → UE) | Blender nose −Y, port +X, Z up. **(x, y, z)₍blender₎ → (X = −y, Y = −x, Z = z)**. `Tools/blender/export_kazah.py` already does this (see `Vehicles/KazahLayout.inl`). | | Randomness | Gameplay uses the seeded `mulberry32` port (`Descent::FRng`). World seeds are fixed (§2.9) so tests are repeatable. `FMath::Rand` is allowed only for cosmetics. | | Helpers | Port these v2 helpers verbatim into `Core/DescentMath.h`: `clamp`, `lerp`, `smooth(e0,e1,x)` (smoothstep), `damp(a,b,k,dt) = lerp(a,b,1−exp(−k·dt))`, `smin(a,b,k)` (`h = max(k−abs(a−b),0)/k; min(a,b) − h²·k/4`), `mulberry32`, 3D simplex noise `makeNoise3(seed)` (`01_core.js:39–64`). | --- ## 1. Architecture ### 1.1 Module, plugins, project settings One runtime module, `TheDescent` (exists). No editor module: assets are made by Python scripts under `TheDescentUE/Tools/` run with `UnrealEditor-Cmd.exe … -run=pythonscript -script=…` (`PythonScriptPlugin`, already enabled; commandlet in `Engine/Plugins/Experimental/PythonScriptPlugin/.../PythonScriptCommandlet.cpp`). `TheDescent.Build.cs` dependencies to add as the milestones need them: | Module | Why | First needed | |---|---|---| | `ProceduralMeshComponent` (plugin `ProceduralMeshComponent`, add to `.uproject`) | terrain chunks, carrier blockout | M1 | | `Slate`, `SlateCore`, `UMG` | neumorphic UI, `UWidgetComponent` dashboards | M2 | | `PhysicsCore` | collision shapes and queries | M2 | | `Json` (already), `RenderCore` (already) | harness | M0 | Not used, do not add: Niagara, MetaSounds, Mover, Water, Chaos Vehicles, Geometry Scripting, Lumen-only features. They are present in the engine but every effect here is specified without them so that nothing depends on hand-authored assets. `Config/DefaultEngine.ini`, `[/Script/Engine.RendererSettings]` (names verified in `Engine/Source/Runtime/Engine/Classes/Engine/RendererSettings.h`): ```ini r.DynamicGlobalIlluminationMethod=0 ; no Lumen GI: ambient is a material term (§7.3), deterministic for tests r.ReflectionMethod=2 ; screen-space reflections r.Shadow.Virtual.Enable=1 ; already set r.CustomDepth=3 ; custom depth WITH stencil (dry-interior mask, §7.4) r.DefaultFeature.AutoExposure=False r.DefaultFeature.MotionBlur=False r.DefaultFeature.LensFlare=False r.DefaultFeature.Bloom=True ; values come from the post-process component (§7.6) r.AllowStaticLighting=False ; already set ``` M0 currently ships `r.DynamicGlobalIlluminationMethod=1` and `r.ReflectionMethod=1` (Lumen). **Change both in M1** to the values above. Reasons: the terrain and the carrier blockout are procedural meshes with no distance fields or Lumen cards, so Lumen would light them from screen traces only; Lumen's temporal gather lags behind a moving headlamp, which is the main light of this game; and the luminance tests of §7.10 need a renderer whose output does not depend on warm-up history. Revisit Lumen for the interior when real static meshes replace the blockout (§11). `[/Script/EngineSettings.GameMapsSettings]` is already set by M0: `GameDefaultMap` and `EditorStartupMap` = `/Game/Maps/Descent_Main`, `GlobalDefaultGameMode=/Script/TheDescent.DescentGameMode`. From M1 on the map holds **only a PlayerStart**: `Tools/ue/import_assets.py` must stop placing fog, exposure and the showcase rig in it, because **everything is spawned by code at BeginPlay** (§1.5) and a second height fog or post-process volume in the map would fight `ADescentEnvironment`. Pressing Play in the editor and running `-game` then give the same world. The showcase rig stays available to the `kazah_showcase` scenario, spawned by the scenario. ### 1.2 Source layout ``` Source/TheDescent/ DescentUnits.h (exists) Core/ DescentMath.h DescentTuning.h DescentGameMode.* DescentSimSubsystem.* DescentSave.* World/ DescentWorldDef.* DescentSDF.* DescentTerrainSubsystem.* TerrainChunk.* SurfaceNets.* DescentOreSubsystem.* DescentShear.* Vehicles/ DescentCarrier.* CarrierLayout.* CarrierBlockout.* CarrierHelm.* CarrierCarousel.* KazahTender.* (exists) KazahLayout.inl (generated) TenderFlight.* TenderTool.* TenderHold.* Player/ DescentCharacter.* DescentCharacterMovement.* DescentPlayerController.* DescentInteraction.* DescentCarry.* DescentVitals.* DescentInventory.* Systems/ DescentItems.* DescentStore.* DescentCargo.* (pipes, packer, boxes) DescentRefinery.* DescentFabricator.* DescentEconomy.* DescentBuild.* DescentPlaceables.* DescentRobotArm.* DescentBiomass.* Life/ DescentFauna.* DescentFlora.* Render/ DescentEnvironment.* DescentMarineSnow.* DescentLightPool.* Sonar/ DescentSonar.* DescentSurveyMap.* Horror/ DescentHorror.* DescentLeviathan.* DescentBeacons.* Audio/ DescentAudio.* UI/ NeuStyle.* NeuWidgets.* DescentHUD.* DescentWindows.* DescentDash.* Debug/ (exists) + DescentCommands_.cpp Tests/ (exists) + one .cpp per system ``` ### 1.3 Class list Actors (A), components and UObjects (U), plain C++ (F), Slate (S). "Sim" means it is stepped by `UDescentSimSubsystem` (§1.4), not by its own `Tick`. | Class | Kind | Owns / does | |---|---|---| | `ADescentGameMode` (exists) | A | Default pawn `ADescentCharacter`, controller `ADescentPlayerController`. `StartPlay` runs the boot sequence §1.5. | | `UDescentSimSubsystem` | `UWorldSubsystem` | The ordered simulation step (§1.4), sim clock, `FastForward`. | | `UDescentSaveGame` | `USaveGame` | §6.12. | | `FDescentWorldDef` | F, immutable after `Build()` | Every SDF primitive, deposit, beacon and landmark of the level (§2.7–2.9). | | `FDescentSDF` | F, thread-safe | `GenRaw`, `Gather`, primitive distance functions (§2.2). | | `UDescentTerrainSubsystem` | `UWorldSubsystem` | Chunk map, density queries, carving, cleanup, streaming, async jobs, component pool (§2.3–2.6). | | `ADescentTerrainActor` | A | Owner of the pooled `UProceduralMeshComponent`s. No logic. | | `FTerrainChunk`, `FSurfaceNets` | F | Chunk data; mesher (static, thread-safe). | | `UDescentOreSubsystem` | `UWorldSubsystem` | Deposit state (mined counters), crystals, loose ore chunks (instanced). Sim. | | `ADescentCarrier` | A | CANTICLE. Root + components below. One per world. | | `UCarrierHelmComponent` | U | Rigid-body state and `CarrierPhysics` (§3.6). Sim. | | `UCarrierBlockoutComponent` | U | Builds hull and interior from `FCarrierLayout` into procedural mesh components with convex collision (§3.2). | | `UCarrierCarouselComponent` | U | Carousel angle, cradles, launch/dock paths (§3.5). Sim. | | `UCarrierSystemsComponent` | U | Hull %, power %, biomass reserve, room lights, flicker. Sim. | | `FCarrierLayout` | F, static tables | Decks, rooms, doors, windows, stairs, anchors, keep-out zones, pipe routes (§3.3). | | `AKazahTender` (exists, extend) | A | The Tender. Parts from the Kazah manifest + components below (§4). | | `UTenderFlightComponent` | U | Flight model, collision, energy, crush (§4.3). Sim. | | `UTenderToolComponent` | U | The single drill-plus-suction tool (§4.5). Sim. | | `UTenderHoldComponent` | U | Ore hold and bio-tank contents, in arrival order (§4.6). | | `ADescentCharacter` | `ACharacter` | The player on foot and swimming. Camera, flashlight, carry point. | | `UDescentCharacterMovement` | `UCharacterMovementComponent` | Walking on the moving carrier; custom swim mode (§5.2–5.3). Engine tick. | | `ADescentPlayerController` | `APlayerController` | Mode state machine (§5.6), Enhanced Input (§5.7), view targets. | | `UDescentInteractionComponent` | U (on character) | Focus search and `E` (§5.4). | | `UDescentCarryComponent` | U (on character) | Carrying an ore box (§5.5). | | `UDescentVitalsComponent` | U (on character) | Nutrition, O₂, health, death/respawn (§5.8). Sim. | | `UDescentInventoryComponent` | U (on character) | 24 slots, hotbar 5, equipment (§6.1). | | `IDescentInteractable` | interface | `GetPrompt`, `GetFocusPoint`, `GetRange`, `GetMinDot`, `CanUse`, `Use(Character)`. | | `UDescentStoreSubsystem` | `UWorldSubsystem` | Carrier storage (items), cargo ledger over ore boxes, upgrades `FUpgradeState`, debt. | | `UDescentCargoSubsystem` | `UWorldSubsystem` | Unload jobs, pipe network, packer, box sockets, robot-arm scheduling (§6.3–6.6). Sim. | | `ADescentOreBox` | A | A physical ore box: type, count ≤ 6 (§6.4). | | `ADescentPlaceable` | A, base | Anything placed with build mode (§6.10). Subclasses: `APlaceableFabricator`, `APlaceableRefinery`, `APlaceableLocker`, `APlaceableBoxRack`, `APlaceableRobotArm`, `APlaceableLight`, `APlaceableFurniture`. | | `ADescentFixture` | A, base | Fixed carrier stations that are *not* placeable: `AFixtureHelm`, `AFixtureNutrientPort`, `AFixtureVat`, `AFixturePacker`, `AFixtureTerminal`, `AFixtureBed`, `AFixtureCarouselConsole`, `AFixtureChartTable`, `AFixtureLadder`. | | `UDescentBuildComponent` | U (on character) | Build / placement mode (§6.10). | | `UDescentFaunaSubsystem` | `UWorldSubsystem` | Fish schools, jellies, polyps; biomass capture; hull exclusion (§6.9). Sim. | | `ADescentEnvironment` | A | Height fog, unbound post-process component, surface sun, material parameter collection writes (§7). Late step. | | `UDescentLightPool` | U (on environment) | Pool of 24 shadowless point lights for flares, glows, bioluminescence (§7.5). | | `UDescentMarineSnowComponent` | `UInstancedStaticMeshComponent` | Marine snow (§7.7). | | `UDescentSonarSubsystem`, `UDescentSurveyMap` | `UWorldSubsystem` | §8.1–8.2. Sim. | | `UDescentHorrorSubsystem`, `ADescentLeviathan`, `ADescentBeacon` | | §8.3. Sim. | | `UDescentAudioSubsystem` | `UWorldSubsystem` | §8.4. Late step. | | `FNeuStyle`, `SNeuPanel`, `SNeuButton`, `SNeuGauge`, `SNeuWindow`, `SDescentHUD`, `SDescentDash`, `SCargoDisplay`, `SSurveyMap`, … | S | §8.5. | | `UDescentDebugSubsystem`, `FDescentStateRegistry`, `FDescentScenario*` (exist) | | §9. | Static data lives in code, not in assets: `DescentItems.cpp` (items, recipes), `CarrierLayout.cpp`, `DescentWorldDef.cpp`, `DescentTuning.h` (every constant in this document, grouped in namespaces `Descent::Tune::Carrier`, `::Tender`, `::Player`, `::Econ`, `::Look`, …). User-tunable values are also console variables (`descent.Look.Bloom`, `descent.Player.NutritionMinutes`, `descent.Terrain.StreamRadius`). ### 1.4 Ownership and tick order **Rule: anything that changes game state is stepped by `UDescentSimSubsystem`, in one fixed order, with one `dt`.** Actor and component `Tick` are for cosmetics only (rotor spin, lamp blink, widget redraw). This is the Unreal form of v2's single `update(dt)` and it is what makes `Descent.Sim.FastForward` (§9.2) and the headless route tests possible. `UDescentSimSubsystem` registers two `FTickFunction`s on the persistent level (`FTickFunction::RegisterTickFunction`, `Engine/Classes/Engine/EngineBaseTypes.h:341`): - **`SimTick` in `TG_PrePhysics`**: calls `Step(min(DeltaSeconds, 0.05))`. `ADescentCharacter` adds it as a tick prerequisite (`FTickFunction::AddPrerequisite`, same header `:426`), so the carrier has already moved when the character's movement component runs. - **`LateTick` in `TG_PostUpdateWork`** (after the camera manager has updated): calls `LateStep(dt)`. `Step(dt)` order (v2 `13_main.js update()` re-ordered for Unreal): | # | Step | Owner | |---|---|---| | 1 | Sim clock; latch intents from input or from debug commands (§5.7) | Sim, PlayerController | | 2 | Carrier: helm intent → `CarrierPhysics` → move actor; sensors at 4 Hz; hull consequences | `UCarrierHelmComponent`, `UCarrierSystemsComponent` | | 3 | Carousel rotation; launch / dock / board animations | `UCarrierCarouselComponent`, PlayerController | | 4 | Tenders: flight of the piloted Tender, drift of abandoned ones, collision, energy, crush; docked recharge | `UTenderFlightComponent` | | 5 | Tender tool: drill (carve + cleanup), suction | `UTenderToolComponent`, Terrain, Ore | | 6 | Vitals: nutrition, O₂, health, honest void, death timer | `UDescentVitalsComponent` | | 7 | Terrain: start fill / mesh jobs, synchronous remesh of carved chunks (budget), apply finished jobs (budget) | `UDescentTerrainSubsystem` | | 8 | Ore: loose chunks, crystals | `UDescentOreSubsystem` | | 9 | Cargo: unload jobs, pipes, packer, robot arms; refinery; fabricator timers | `UDescentCargoSubsystem`, placeables | | 10 | Fauna, flora, biomass capture | `UDescentFaunaSubsystem` | | 11 | Sonar ray budget, contacts; map visits | `UDescentSonarSubsystem`, `UDescentSurveyMap` | | 12 | Horror director, beacons, ending | `UDescentHorrorSubsystem` | Then the engine runs `UDescentCharacterMovement` (walking or swimming) in the same tick group. `LateStep(dt)`: interaction focus → environment (fog, post-process, sun, parameter collection, dry/wet state) → light pool commit → marine snow → audio parameters → HUD model (10 Hz) → dashboards (8 Hz). ### 1.5 Boot sequence (`ADescentGameMode::StartPlay`) 1. `FDescentWorldDef::Build()` (deterministic, < 200 ms). 2. Spawn `ADescentEnvironment`, `ADescentTerrainActor`, `ADescentCarrier` at the start pose (0, 0, −30), ψ = 0. 3. Spawn three `AKazahTender`s into the carousel cradles; spawn start fixtures and start placeables (§3.4). 4. `UDescentTerrainSubsystem::StreamInitialBlocking(Focus = carrier, Radius = 120 m)` with a `ParallelFor` over fill + mesh, then apply all. Target < 3 s. 5. Place the character on the bridge (carrier-local (40.5, 0, 1.5), facing the bow) *(v2)*; mode `Title`, then `Foot` on first click (or immediately when a scenario is running). 6. Load the save if `Continue` was chosen (§6.12). ### 1.6 v2 file → Unreal counterpart | v2 file | Unreal counterpart | |---|---| | `00_head.html` (CSS tokens, HUD DOM, overlays) | `UI/NeuStyle.*`, `UI/NeuWidgets.*`, `UI/DescentHUD.*`, `UI/DescentWindows.*` (Slate, §8.5) | | `01_core.js` (math, datum, noise, renderer, `waterMat`, composer, virtual lights) | `Core/DescentMath.h`, `DescentUnits.h`; `Render/DescentEnvironment.*` + materials made by `Tools/make_materials.py` (§7); `Render/DescentLightPool.*` | | `02_audio.js` | `Audio/DescentAudio.*` + `Tools/gen_audio.py` (§8.4) | | `03_world.js` | `World/DescentWorldDef.*`, `World/DescentSDF.*` (§2.2, 2.7–2.9) | | `04_terrain.js` | `World/DescentTerrainSubsystem.*`, `TerrainChunk.*`, `SurfaceNets.*` (§2.3–2.6) | | `05_life.js` | `Life/DescentFauna.*`, `Life/DescentFlora.*`, `World/DescentOreSubsystem.*` (loose ore, crystals), `Render/DescentMarineSnow.*`, flares in `Player/` | | `06_carrier.js` | `Vehicles/DescentCarrier.*`, `CarrierLayout.*`, `CarrierBlockout.*`, `CarrierHelm.*`, `CarrierCarousel.*` (§3) | | `06_routes.js` | `World/DescentRoutes.*` (waypoints, autopilot, `ClearRoute`) used by tests (§2.10) | | `07_vehicles.js` | `Vehicles/KazahTender.*`, `TenderFlight.*`, `UI/DescentDash.*` (§4) | | `08_items.js` | `Systems/DescentItems.*`, `DescentStore.*`, `DescentRefinery.*`, `DescentFabricator.*`, `DescentEconomy.*` (§6) | | `09_sonar_map.js` | `Sonar/DescentSonar.*`, `Sonar/DescentSurveyMap.*` (§8.1–8.2) | | `10_player.js` | `Player/*` (§5), `Vehicles/TenderFlight.*`, `TenderTool.*` | | `11_ui.js` | `UI/DescentHUD.*`, `UI/DescentWindows.*` | | `12_horror.js` | `Horror/*` (§8.3) | | `13_main.js` | `Core/DescentSimSubsystem.*`, `Core/DescentGameMode.*`, `Debug/*` | ### 1.7 Engine features relied on (all verified in the 5.8.1 source) | Feature | Header (under `E:\Tools\UE_5.8\Engine\`) | |---|---| | `UProceduralMeshComponent::SetProcMeshSection`, `SetCollisionConvexMeshes` | `Plugins/Runtime/ProceduralMeshComponent/Source/ProceduralMeshComponent/Public/ProceduralMeshComponent.h` | | `UE::Tasks::Launch` | `Source/Runtime/Core/Public/Tasks/Task.h` | | Character on a moving base: `bFastAttachedMove`, `bStayBasedInAir`, `bImpartBaseVelocityX/Y/Z`, `PhysCustom`, `UpdateBasedMovement` | `Source/Runtime/Engine/Classes/GameFramework/CharacterMovementComponent.h` | | Enhanced Input in code: `UInputMappingContext::MapKey`, `AddMappingContext`, `InjectInputForAction` | `Plugins/EnhancedInput/Source/EnhancedInput/Public/InputMappingContext.h`, `EnhancedInputSubsystemInterface.h` | | `FPostProcessSettings` (bloom, manual exposure), `EBlendableLocation` | `Source/Runtime/Engine/Classes/Engine/Scene.h`, `Classes/Engine/BlendableInterface.h` | | `UExponentialHeightFogComponent` setters incl. `SetVolumetricFogStartDistance` | `Source/Runtime/Engine/Classes/Components/ExponentialHeightFogComponent.h` | | Lights: `bUseInverseSquaredFalloff`, `SetLightFalloffExponent`, `SetVolumetricScatteringIntensity` | `Classes/Components/PointLightComponent.h`, `LightComponent.h` | | Custom depth / stencil per primitive | `Classes/Components/PrimitiveComponent.h` (`SetRenderCustomDepth`, `SetCustomDepthStencilValue`) | | Instanced meshes with per-instance data | `Classes/Components/InstancedStaticMeshComponent.h` (`SetNumCustomDataFloats`, `SetCustomData`, `BatchUpdateInstancesTransforms`) | | World-space Slate dashboards | `Source/Runtime/UMG/Public/Components/WidgetComponent.h` (`SetSlateWidget`, `SetDrawSize`, `SetManuallyRedraw`, `RequestRedraw`) | | Slate drawing | `Source/Runtime/SlateCore/Public/Rendering/DrawElementTypes.h` (`FSlateDrawElement::MakeBox`, `MakeLines`, `MakeCustomVerts`), `Public/Brushes/SlateRoundedBoxBrush.h` | | Materials by script | `Source/Editor/MaterialEditor/Public/MaterialEditingLibrary.h`; `Runtime/Engine/Public/Materials/MaterialExpressionCustom.h`, `MaterialExpressionPerInstanceCustomData.h`, `MaterialParameterCollection.h` | | Audio component volume, pitch, low-pass | `Classes/Components/AudioComponent.h` | | Collision queries and penetration | `Classes/Engine/World.h` (`SweepSingleByChannel`, `OverlapMultiByChannel`), `PrimitiveComponent.h` (`ComputePenetration`) | | Save game, compression | `Classes/Kismet/GameplayStatics.h`, `Runtime/Core/Public/Misc/Compression.h` | | Runtime textures | `Classes/Engine/Texture2D.h` (`CreateTransient`, `UpdateTextureRegions`) | | Fixed time step for runs | `-UseFixedTimeStep -FPS=30` (`Runtime/Launch/Private/LaunchEngineLoop.cpp`) | --- ## 2. Terrain and world ### 2.1 Decisions | Decision | Choice | Why | |---|---|---| | World representation | Analytic SDF (`FDescentSDF::GenRaw`) sampled into 20 m chunks of 1.25 m voxels, edited by carving. Same as v2. | Proven; carving, collision, sonar and the map all read one field. | | Mesher | Surface nets with gradient normals, on worker threads. Port of `04_terrain.js meshChunk`. | Clean carving (V7b). | | Mesh component | One `UProceduralMeshComponent` per meshed chunk, pooled, fed with `SetProcMeshSection` from a section built on a worker. | Vertex format (position, normal, colour, UV0–UV3) maps 1:1; no intermediate mesh structure; no asset needed. | | Terrain physics collision | **None.** Chunks never cook collision (`bEnableCollision = false`, component `NoCollision`). Every interaction with rock queries the density field. | Nothing walks on rock: the player is on foot only inside the carrier. Tender, swimmer, carrier, loose ore, fish, flares and sonar all used the density field in v2. It removes trimesh cooking, the largest cost of runtime terrain in Unreal. | | World bounds | **None.** The chunk map is a hash map keyed by integer chunk coordinates; chunks are classified lazily. Outside the designed voids the SDF is solid rock. | U7: no clamp exists anywhere. See §2.11 for how the playable space ends. | ### 2.2 The density function `double FDescentSDF::GenRaw(const FVector& Pm, const FPrimList& L)`: signed, **positive = rock, negative = water**, approximately metres near surfaces. `Gen = clamp(GenRaw, −4, +4)` (`CLAMP_D = 4`). Port of `03_world.js genRaw` with the axes of §0. ``` open = +1e9; obig = +1e9 for seg in L.Segs: d = SegDistR(seg, P); if seg.bBig: obig = min(obig, d) else open = min(open, d) if L.bCanyon: fl = Zf(X) + 4*sin(0.07*Y - 0.02*X) + 3*sin(0.045*X) hw = 70 + 0.18*max(0, Z - fl) cyn = max(|Y - Yc(X)| - hw, fl - Z, X - 850, -250 - X) cyn = min(cyn, (40 + 3*sin(0.03*Y)*cos(0.025*X)) - Z) // everything above the rim is open sea open = smin(open, cyn, 10) for c in L.Chambers: open = smin(open, (length((P - c.C)/c.R) - 1) * min(c.R.x, c.R.y, c.R.z), c.K) for w in L.Wells: obig = smin(obig, max(hypot(X-w.X, Y-w.Y) - w.R, Z - w.Z1, w.Z0 - Z), 12) if L.bAbyss: open = smin(open, AbyssSDF(P), 14) for p in L.Pores: open = smin(open, length(P - p.C) - p.R, 2.6) for z in L.Sponges: open = smin(open, SpongeSDF(z, P), 4) n1 = noise3(0.02*X, 0.02*Y, 0.024*Z); n2 = noise3(0.07*X + 11, 0.07*Y, 0.07*Z) rock = open + 5.5*n1 + 1.4*n2 + (Z < -330 ? 1.8*noiseB(0.05*P) : 0) if obig < 1e8: rb = obig + 2.2*n1 + 0.8*n2; rock = (rock < 1e8) ? smin(rock, rb, 8) : rb // carrier voids: low noise for s in L.Shelves: rock = max(rock, ShelfRock(s, P, 1.4*noiseB(0.09*P) + 0.8*n2)) for p in L.Pinches: rock = max(rock, PinchRock(p, P) + 0.7*n2) for r in L.Rings: rock = max(rock, RingRock(r, P) + 0.7*n2) return rock ``` `Yc(X) = 30·sin(0.006·X) + 12·sin(0.017·X)`; `Zf(X) = lerp(−120, −260, smooth(−100, 800, X))`. `noise3 = makeNoise3(20193)`, `noiseB = makeNoise3(771)`. Primitive functions (ported from `03_world.js:129–165`, axes converted): - `SegDistR(seg, P)`: distance to the segment minus the radius interpolated along it. - `ShelfRock(s, P, noise) = min( s.T + noise − |Z − s.Zc|, SlotSDF(s, X, Y), s.Rclip − hypot(X−s.X, Y−s.Y), min over crack segments (SegDist − s.CrackR) )`. `SlotSDF` is a stadium: `a` = offset along the slot axis, `o` = across; `al = clamp(a, −(Half−W), Half−W)`; `hypot(a − al, o) − W`. - `PinchRock(p, P) = min( p.Half − |along|, (hypot(u/p.Ax, v/p.Ay) − 1)·min(p.Ax, p.Ay), (TubeR + 14) − hypot(u, v) )` with `along`, `u` (horizontal, across the axis) and `v` (up) the coordinates of `P − p.C` in the pinch frame. - `RingRock(r, P) = min( r.Half − |Z − r.Zc|, hypot(X−w.X, Y−w.Y) − r.RInner, (w.R + 12) − hypot(…) )`. - `AbyssSDF(P) = max( hypot(X−1250, Y+300) − Rz − 5·sin(0.05·Z + 0.04·Y), Z + 1400, −1640 − Z )`, `Rz = lerp(70, 6, smooth(−1400, −1640, Z))`. - `SpongeSDF(zone, P) = max( (max(|nA|, |nB|) − 0.085)·28, BoxSDF(zone.Box, P) + 12 )` with `nA = noise3(0.016·P + 37)`, `nB = noiseB(0.016·P − 53)`. Two ridged noises intersected give worm-like tunnels 5–9 m wide. `Gather(Center, Radius, FPrimList&)` collects the primitives that can affect a region (port of `gatherLists`, same margins: segment `+10`, chamber `+16`, pore `+8`, well `+14`, shelf `+8/+10`, pinch `+48`). Because the new world has about 600 segments and 900 pores, `FDescentWorldDef` keeps a uniform 80 m bucket grid of primitive indices built once in `Build()`; `Gather` reads the buckets it overlaps. `GenQuick(P)` (for points whose chunk has no data) caches one gathered list per 40 m cell in an LRU map of 4,096 entries. ### 2.3 Chunks | Constant | Value | |---|---| | Voxel size `VS` | 1.25 m *(v2)* | | Core samples per axis `CN` | 16 → chunk size `CS` = 20 m *(v2)* | | Padding | 2 samples each side → `P = CN + 4 = 20`, `P³ = 8,000` samples *(v2)* | | Density storage | `float[8000]` + `uint8 Cut[8000]` = 40 KB per filled chunk | | Chunk key | `FIntVector C`, chunk covers `[C·CS, (C+1)·CS)`; sample `(i, j, k)` is at `((C·CN − 2) + (i, j, k))·VS` | States: `Unclassified → Uniform(±4) | Candidate`; `Candidate → Filling → Mixed | Empty`; `Mixed → Meshing → Meshed`. Flags: `bModified` (carved at least once; data is then kept for ever and saved), `Version` (incremented by every edit; a job result with a stale version is dropped and re-queued). Classification (lazy, on first touch): `v = GenRaw(ChunkCentre)`; `|v| > CS·0.87 + 10` → `Uniform(sign v)`, else `Candidate`. Chunks that intersect a sponge zone box grown by 12 m are always `Candidate` (the sponge is not a distance field). ### 2.4 Meshing (worker thread) Port `04_terrain.js:104–167` exactly: cells `1..CN+1` on each axis; one vertex per mixed cell at the mean of its edge crossings; normal = negated, normalised trilinear blend of the eight corner central-difference gradients; one quad per sign change along +x, +y, +z for samples `2..CN+1`. The 2-sample padding makes neighbouring chunks produce identical border vertices, so there are no seams as long as every copy of a sample is edited together (§2.5). Output: one `FProcMeshSection` (`ProcVertexBuffer`, `ProcIndexBuffer`, `SectionLocalBox`, `bEnableCollision = false`): | Vertex field | Content | |---|---| | `Position` | chunk-local uu (component sits at the chunk origin) | | `Normal` | gradient normal; `Tangent` = any unit vector perpendicular to it | | `Color.rgb` | `RockColor(P, N)` below, sRGB-encoded; `Color.a` = cut amount (max `Cut` of the 8 corners / 255) | | `UV0` | `(OreType 0..3, OreStrength 0..1)` from `DepositAt` (0 = none, 1 ferrite, 2 cobalt, 3 pearl); strength = `min(1, 1.6·k)`; zero when the deposit is mined out | | `UV1` | `(Basalt 0..1, 0)` = `saturate(ClaimDist(P) / 40)` (§2.11) | `RockColor` (palette *(v2)*; blend heights *(new)*): ``` tBase #3a3d40 tDark #222428 tSilt #6b6252 tRust #6a3e2a tBio #3c5a44 hBase #5a4232 hDark #2e231c hCrust #9a9282 sBase #2a2432 sBand #4a3f58 wH = smooth(-330,-400,Z); wS = smooth(-930,-1000,Z) n = noise3(.05X,.05Y,.08Z); m = noiseB(.018X,.018Y,.03Z); up = smooth(.35,.85,N.z) c = lerp(tBase,tDark,smooth(-.2,.6,n)); c = lerp(c,tRust,.6*smooth(.45,.7,m)); c = lerp(c,tSilt,.85*up*smooth(-.4,.3,n)) if Z > -110: c = lerp(c,tBio,.6*up*smooth(0,.6,m)*smooth(-110,-40,Z)) if wH > 0: c = lerp(c, lerp(lerp(hBase,hDark,smooth(-.3,.5,n)), hCrust, .5*smooth(.5,.8,m)+.25*up), wH) if wS > 0: c = lerp(c, lerp(sBase,sBand,.5+.5*sin(.8Z+3n)), wS) ``` Triangle winding: Unreal is left-handed; after the port take the screenshot `terrain_basic_lit` (§10 M1). If the rock is lit from the inside, swap two indices in `Quad()` once. ### 2.5 Carving, cleanup, queries (game thread) `int32 Carve(Centre, Radius, Rate, Dt, bIgnoreHardness = false)`: port of `04_terrain.js carve`: - For every sample within `Radius + 2`: `target = dist − Radius`; `nv = smin(old, target, 1.6)`; `nv = max(nv, old − MaxDrop)`, `MaxDrop = Rate·Dt / Hardness(P)`; `nv = max(nv, −4)`; write if `nv < old`. - `Cut = max(Cut, 200)` where `dist < Radius + 0.6`. - Every chunk that holds a copy of the sample (core or padding) is written in the same call. Only the chunk whose **core** owns the sample counts removal and ore. - A sample that goes from rock to water counts as removed. If it lies in a deposit that is not mined out: `Deposit.Removed++`; **every 6 removed core samples → `Mined++` and one loose ore chunk** *(v2)* via `OnOreFreed(Deposit, P)`. - Samples with `ClaimDist(P) > 40` are never written (dense basalt, §2.11). - Returns removed count. Touched chunks: `bModified = true`, `Version++`, added to the dirty set. `Hardness(P)` *(new bands)*: 1.0 for Z > −260; 1.2 to −560; 1.5 to −960; 1.9 below; multiplied by `1 + 7·saturate(ClaimDist(P)/40)` in the basalt ramp. `CleanupFloating(Centre, Radius)`: port of `cleanupFloating`: region half-size `ceil((Radius + 4)/VS)` samples; flood-fill rock from the region boundary; any rock sample not reached is set to −0.8 and reported through `OnRockFreed`. The tool calls it every 0.35 s while drilling with `Radius = DrillR + 1.5`, and once with `DrillR + 2` when drilling stops *(v2)*. Query API (metres, game thread only; chunk pointer of the last query is cached): ```cpp double Density(const FVector& Pm) const; // trilinear in chunk data; GenQuick clamp when no data; ±4 for Uniform FVector Gradient(const FVector& Pm, double H = 0.6) const; // normalised, points INTO rock; (0,0,1) if degenerate double Raycast(const FVector& Om, const FVector& Dir, double MaxD, double MinStep = 0.3) const; // sphere trace + 7 bisections; −1 if no hit; 0 if origin in rock bool PushOutSphere(FVector& InOutCentreM, double RadiusM, FVector* OutNormal = nullptr) const; // pen = Radius + Density; move by −Gradient·pen ``` Step rule in `Raycast`: `t += max(MinStep, −0.85·d)` *(v2)*. ### 2.6 Streaming, threading, budgets | Item | Value | |---|---| | Foci | camera; the carrier if it is more than 70 m from the camera; the piloted Tender when the camera is not in it | | Load radius `R` / unload radius `RU` | 160 m / 200 m (cvar `descent.Terrain.StreamRadius`; low preset 120 / 150) | | Queue rebuild | every 0.5 s or when the first focus has moved 10 m; nearest first | | Fill job (worker) | `Gather` + 8,000 `Gen` samples; returns `Mixed` or `Empty` (an empty chunk frees its data until edited). Target ≤ 0.6 ms. | | Mesh job (worker) | gets a **copy** of `Data` and `Cut` (40 KB memcpy on the game thread) plus the deposit list; returns the section. Target ≤ 0.3 ms. | | Launching | `UE::Tasks::Launch`; at most 12 jobs in flight; results through a multi-producer queue | | Apply (game thread) | at most 1.5 ms per frame: take a pooled component, `SetProcMeshSection(0, Section)`, set its location; drop results whose `Version` is stale | | Carved chunks | remeshed **synchronously** on the game thread, nearest to the camera first, 2.5 ms per frame, each chunk at most once per 3 frames; the rest goes to the async queue | | Unload | every 0.33 s: a meshed chunk farther than `RU` from every focus returns its component to the pool; unmodified chunks also free `Data` | | While `Filling` | queries fall back to `GenQuick`; a carve that hits a chunk without data fills it synchronously first | | Memory cap | 4,000 filled chunks (160 MB). Modified chunks are never evicted. | | Render budget | ≤ 900 meshed chunks, ≤ 900k terrain triangles at `R` = 160 m. Dump both in the `terrain` state section. | Chunk components: `CastShadow = true`, `bAffectDistanceFieldLighting = false`, material `MI_Terrain` (§7.8), no collision, no navigation. ### 2.7 The new world: extents and strata (U8) The designed region ("the claim") is the box **X −330..1,560, Y −980..480, Z −1,700..+60**: 1,890 × 1,460 × 1,760 m, about 19 times the volume of v2 (600 × 560 × 700 m). The Tender's descent range goes from 500 m (v2) to 1,400 m. Voids are 2–3 times larger in every dimension (the Great Void is 720 × 640 × 280 m against v2's 320 × 230 × 104 m). | Stratum | Z (m) | Depth (m) | Character | HUD band name | Hardness | Tender rating needed | |---|---|---|---|---|---|---| | Open sea above the rim | > +40 | < 1,740 | the Shear (§2.11) | — | — | — | | S0 The Throat (canyon) | +40 … −260 | 1,740–2,040 | black basalt, silt, last surface light, light shafts | THE THROAT; below 2,040 m THROAT FLOOR | 1.0 | base 2,300 | | S1 Upper Honeycomb | −260 … −560 | 2,040–2,340 | porous rock, first voids, ferrite, some cobalt | THE HONEYCOMB (from 2,150 m) | 1.2 | base 2,300 | | S2 The Great Void | −560 … −960 | 2,340–2,740 | kilometre-scale dark, cobalt, first pearl | THE HONEYCOMB | 1.5 | Mk I 2,700 | | S3 Seam intrusion | −960 … −1,420 | 2,740–3,200 | laminated purple rock, pearl | HONEYCOMB · SEAM INTRUSION (from 2,700 m) | 1.9 | Mk II 3,150 | | Abyss | < −1,420 | > 3,200 | the shaft below the goal; the Floor's glow | BELOW SURVEY (from 3,180 m) | 1.9 | none sold | Surface light *(v2)*: `L(Z) = clamp(exp(−(40 − Z)/68), 0, 1)` (0.36 at the start, 0.05 at Z −165, < 0.01 below −300). Water temperature: `4.2 + 9·clamp((Depth − 1740)/1400, 0, 1) + (Depth > 2300 ? 6 : 0)` °C. Carrier start: (0, 0, −30), ψ = 0, depth 1,810 m *(v2)*. **Goal:** bring the carrier to the Seam Gallery, anchor depth 3,105 m (Z −1,325). ### 2.8 The carrier route network The CANTICLE is 122 m long (X −62..+60 with the stern shroud), 30.2 m wide with pods, 31.1 m tall (keel −12.9, sail top +18.2). Its circumscribed radius in plan is 61.8 m. It turns only in open water, descends only by ballast, and pitches at most ±10°. Fit rules that every carrier-scale void obeys (after the chokes are dug): | Void kind | Rule | Margin | |---|---|---| | Well (vertical cylinder) | radius **80 m** | 18 m around the hull's circumscribed circle, minus wall noise ≤ 3 m | | Gallery (tube) | radius **34 m**, slope ≤ 9.5°, bend centreline radius ≥ 150 m (sagitta of a 120 m chord: 12 m) | sideways: 15.1 m (pods) + 12 m (bend) + 3 m (noise) = 30.1 m ≤ 34 m; upward: 18.2 m (sail) + 3 m = 21.2 m ≤ 34 m | | Chamber | every semi-axis ≥ 60 m in plan where the carrier must turn | — | | Wall noise on carrier voids | ±2.2 m (n1) ±0.8 m (n2) instead of ±5.5 / ±1.4 | keeps clearances honest | Chambers (ellipsoids: centre, semi-axes along X, Y, Z, blend k): | Id | Name | Centre | Semi-axes | k | Notes | |---|---|---|---|---|---| | H1 | HONEYCOMB I | (400, −470, −200) | (150, 130, 62) | 10 | junction, ore-rich, pores | | H2 | THE GREAT VOID | (640, −230, −700) | (360, 320, 140) | 12 | leviathan zone; KILO-7 station on its floor | | H3 | THE UNDERCROFT | (900, −420, −1,020) | (260, 220, 95) | 12 | wreck of the *ANTIPHON* | | G | SEAM GALLERY (**goal**) | (1,250, −300, −1,330) | (300, 240, 90) | 12 | ending: carrier origin inside the ellipsoid scaled 0.7 and Z ≤ −1,290 | | ABYSS | — | axis (1,250, −300) | r 70 → 6 | 14 | Z −1,400 … −1,640, closed at the bottom | Wells (radius 80): | Id | Name | Axis (X, Y) | Z top … bottom | Joins | |---|---|---|---|---| | WA | THE THROAT WELL | (760, −25) | −250 … −665 | canyon floor → Great Void roof | | WB | THE BORE | (430, −420) | −200 … −700 | H1 → Great Void south-west flank | | WC | THE DROP | (820, −330) | −800 … −950 | Great Void floor → Undercroft roof | | WD | THE GULLET | (1,060, −380) | −1,080 … −1,280 | Undercroft floor → Seam Gallery roof | Galleries (radius 34, low noise; turtle path: start point, heading ψ, pitch, then steps `s L` = straight L metres, `t Δψ R` = turn by Δψ degrees on centreline radius R; one waypoint every ≤ 8 m, port of `turtlePath`): | Id | Name | Start | ψ, pitch | Steps | Length | Ends at | |---|---|---|---|---|---|---| | B | THE LONG GALLERY | (−20, −62, −105) | −75°, −7° | s 120 · t +35 R240 · s 140 · t +20 R240 · s 60 | 550 m | ≈ (322, −449, −172), inside H1 | | S | THE HAIRPIN | (900, −230, −765) | 0°, −9.5° | s 380 · t −180 R150 · s 301 | 1,152 m | ≈ (978, −526, −955), inside H3 | Chokes (rock added back with `max`; each one stops the carrier and lets a Tender through): | Id | Name | Where | Geometry | What the carrier needs | Dig | |---|---|---|---|---|---| | SA | THROAT COLLAPSE | shelf in WA, Zc −350 | half-thickness 3, clip radius 88; slot along ψ 0°: half-length 52, half-width 14; Tender crack r 3.4 through (760, 35, −339) → (764, 39, −350) → (758, 43, −361) | slot ≥ 129 × 34 m (half-length 64.5, half-width 17) | ≈ 8,400 m³ | | SC | DROP COLLAPSE | shelf in WC, Zc −880 | half-thickness 3, clip 88; slot along ψ −79°: half-length 50, half-width 13; crack r 3.3 through (881, −318, −869) → (885, −322, −880) → (879, −326, −891) | same | ≈ 10,100 m³ | | NEEDLE | THE NEEDLE | pinch in B at s = 330 m | half-length 7; opening semi-axes 26 (horizontal) × 15 (vertical) | vertical semi-axis ≥ 18.5 m | ≈ 4,000 m³ | | HTHROAT | HAIRPIN THROAT | pinch in S at s = 950 m (straight return leg) | half-length 6; opening semi-axes 12 × 12 | semi-axes ≥ 17 (horizontal) × 18.5 (vertical) | ≈ 6,400 m³ | | BRING | THE BORE PINCH | ring in WB, Zc −450 | half-height 7, inner radius 56 | two notches, for bow and stern: clear radius ≥ 64 m along the hull axis, 34 m wide | ≈ 4,500 m³ | | DRING | THE GULLET RING | ring in WD, Zc −1,180 | half-height 7, inner radius 48 | same notches | ≈ 9,500 m³ | Dig volumes are the rock between the natural opening and the hull's swept outline plus 1.5 m. At the Mk I drill's ≈ 54 m³/s (v2 measurement) a choke takes 2–3 minutes of drilling, i.e. two or three Tender charges. Carrier routes to the goal. Every route needs digging at each tier. The two ways into the Great Void cost about the same rock (SA ≈ 8,400 m³ against NEEDLE + BRING ≈ 8,500 m³): the Throat route is short, the Long Gallery route is longer but passes the ore of Honeycomb I. The two ways out of it (SC, or HTHROAT) differ the same way. 1. **THROAT ROUTE.** Canyon → WA (dig SA) → Great Void → WC (dig SC) → Undercroft → WD (dig DRING) → Seam Gallery. 2. **LONG GALLERY ROUTE.** Canyon → gallery B (dig NEEDLE) → H1 → WB (dig BRING) → Great Void → then as route 1. 3. **HAIRPIN ROUTE.** Route 1 or 2 to the Great Void → gallery S (dig HTHROAT) → Undercroft → WD (dig DRING) → Seam Gallery. Canyon: X −250..850, 173 m wide at the carrier's start height, floor Z −120 falling to −260; open to the sea above Z +40 (formula in §2.2). ### 2.9 Tender-only content, landmarks and procedural variation Designed Tender tubes (Catmull-Rom through the points, radius interpolated with smoothstep; one segment per ≤ 3 m; port of the non-turtle branch of `TUBE_DEFS`). Points are (X, Y, Z, radius): | Id | Name | Points | Purpose | |---|---|---|---| | E1 | EAST SPUR | (30, 78, −62, 12) (45, 125, −72, 10) (60, 155, −78, 9) (68, 172, −84, 12) | ore dead end near the start | | E2 | WEST SPUR | (70, −45, −95, 11) (85, −115, −104, 9) (105, −140, −108, 9) | ore dead end | | C | WRECK HOLLOW | (260, 80, −165, 18) (285, 150, −178, 18) (310, 200, −190, 17) (330, 235, −200, 22) | leads to chamber DEAD | | X | THE CRACK | (375, 280, −208, 3.6) (392, 300, −214, 3.3) (410, 322, −224, 3.4) (424, 340, −232, 4.2) | leads to the honest void | | D | THE CRAWL | (450, −40, −210, 5.5) (440, −130, −226, 4.6) (455, −210, −218, 5.0) (430, −290, −208, 5.2) (415, −345, −200, 6.5) | Tender shortcut canyon → H1 | | LG | GROTTO PASSAGE | (620, 40, −240, 9) (650, 110, −252, 8) (670, 160, −262, 10) | leads to the Lantern Grotto | | CH | THE CHIMNEYS | (330, −520, −245, 6) (318, −535, −380, 5) (345, −510, −500, 5.5) (372, −478, −560, 6) | H1 floor → the Bore below its pinch | | E3 | BLIND GALLERY | (700, 60, −705, 14) (735, 120, −712, 12) (760, 165, −716, 11) | dead end off the Great Void, ore | | SV | SIEVE MOUTH | (470, −450, −770, 8) (440, −530, −795, 7) (430, −620, −820, 9) | Great Void → the Sieve | | SD | SIEVE DRAIN | (640, −600, −1,000, 7) (720, −540, −1,020, 8) | the Sieve → Undercroft | | UP | THE UNDERPASS | (1,080, −320, −1,062, 7) (1,160, −262, −1,140, 6) (1,225, −232, −1,215, 6) (1,258, −240, −1,262, 8) | Undercroft → Seam Gallery, Tender only | Side chambers: | Id | Name | Centre | Semi-axes | k | Notes | |---|---|---|---|---|---| | DEAD | WRECK HOLLOW | (345, 250, −204) | (46, 40, 30) | 8 | derelict Tender at (355, 262, −226), 2193 survey probe at (338, 240, −226), beacons b1, b2 | | LANTERN | LANTERN GROTTO | (690, 205, −268) | (60, 55, 34) | 8 | dense bioluminescent polyps and lanternfish: the first reliable biomass (U3) | | HONEST | HONEST VOID | (436, 356, −236) | (26, 26, 20) | 5 | full pressure: 14 HP/s to a Tender, 25 health/s to a swimmer | | HONEST2 | HONEST VOID II | (520, −760, −900) | (30, 30, 22) | 5 | inside the Sieve; the sponge may open into it | Sponge zone **THE SIEVE**: box X 300..760, Y −900..−560, Z −1,010..−790 (formula in §2.2). A Tender-only maze between the Great Void and the Undercroft, pearl-rich, needs the Mk I rating to enter and Mk II for its lower half. Landmarks (blockout meshes built in code, each a class under `Horror/` or `Life/`): | Landmark | Where | What | |---|---|---| | Derelict BT-01 and survey probe HDR-01 | Wreck Hollow | a dark Kazah hull without lights; a 3.2 m rusted probe | | KILO-7 depth station (gone quiet) | Great Void floor (560, −330, −826) | three 6 m cylinders, a docking ring, dead lamps, one red beacon still blinking (b6) | | *ANTIPHON*, a Pilgrim-class hull | Undercroft floor (980, −520, −1,105) | a 60 m hull broken in two, a carrier that came before you (b7) | | The Floor's glow | below the Abyss | no geometry: a magenta term in the water colour for downward view directions (§7.2), pulsing with the heartbeat | Procedural variation, generated once in `FDescentWorldDef::Build()` from fixed seeds, so every run and every test sees the same world: | Layer | Seed | Rule | |---|---|---| | Wall noise | 20193, 771 | §2.2; the craggy look of all walls | | Pores | 99 | 900 spheres, radius 2.5–10.5, on the walls of H1, H2, H3, G and of Tender tubes below Z −330 (v2 placement: point on the surface + `radius·(0.1..0.8)` outward) | | Worm tunnels | 5150 | 36 random-walk tubes: start from a random wall point of H1/H2/H3/G/DEAD/LANTERN (march to the wall, then inward along the normal), radius 3–6 m, 60–220 m long in 8 m steps, heading jitter ±25° and pitch jitter ±15° per step; 40% end in a pocket chamber (semi-axes 8–16 m) | | Deposits | 4242 | §2.12 | | Flora, polyps, jellies, school homes | 12, 13 | §6.9 | **Rejection rule for every procedural void (pores, worms, pockets, sponge):** it is discarded (or, for the sponge, masked with `max(SpongeSDF, 30 − d)`) if it comes within 30 m + its own radius of any well or gallery axis (`nearCarrierRoute` in v2). Designed landmarks and chokes therefore always win over procedural content, and no procedural void can open a carrier-sized bypass around a choke. Chokes are applied last with `max`. Dead-contractor beacons (range 24 m; text typed out and cut with `[SIGNAL LOST]`). b1–b5 keep the v2 texts (`03_world.js:273–279`), with "Spiral"/"Hairpin" wording as in the code: | Id | Name | Position | Text | |---|---|---|---| | b1 | CONTRACTOR H-19877 | (350, 255, −222) | v2 b1 | | b2 | SURVEY PROBE 2193 | (338, 240, −224) | v2 b2 | | b3 | CONTRACTOR H-20114 | (448, −175, −222) | v2 b3 | | b4 | CONTRACTOR H-18830 | 12 m before HTHROAT on gallery S | v2 b4 | | b5 | CONTRACTOR H-20390 | (820, −330, −872) | v2 b5 | | b6 | DEPTH STATION KILO-7 | (560, −330, −826) | "KILO-7 automatic. Station population: zero. Last docking: four hundred and eleven days. Fuel, air and trade remain available to contract holders. Please do not attempt to answer the knocking on the lower hatch. Tethys reminds you that the knocking is" | | b7 | PILGRIM-CLASS *ANTIPHON* | (980, −520, −1,105) | "ANTIPHON carrier log. We cut the Drop wide enough and she went through like a hymn. Hull at sixty percent. The crew wants to hold here but the stratum is right under us, I can see the well. Something came up it an hour ago and has not gone back down. It is still under the" | | b8 | CONTRACTOR H-17002 | (500, −740, −895) | "The Sieve is all pearl. I have a full hold and I cannot find the way I came in. The sonar says there is a big room behind this wall, pressure reads wrong, reads honest. If I cut through I can use it to turn around. Cutting now. The wall is" | ### 2.10 Route data for tests (`World/DescentRoutes.*`) Port `06_routes.js` (waypoints, `apControl` autopilot, `sweepCarve`, `clearRoute`). Waypoints `(X, Y, Z, speed, heading or none, hold)`: - Common tail `T`: (820, −330, −740, 0.9) · hold ψ −79° · (820, −330, −990, 0) · turn ψ −12° · (1,060, −380, −1,040, 0.9) · hold · (1,060, −380, −1,310, 0) · (1,200, −320, −1,325, 0.5) **GOAL**. - **R1**: canyon (0, 0, −30) → (600, −20, −60, 1.6) → (760, −25, −200, 0.9) · hold ψ 0° · (760, −25, −640, 0) · turn ψ −79° · `T`. - **R2**: (−20, −30, −105, 1.2) · turn ψ −75° · `galleryPts(B, every 30 m)` · (430, −420, −200, 0.5) · hold · (430, −420, −680, 0) · turn ψ 13° · `T`. - **R3**: R1 to (760, −25, −640) · turn ψ −56° · (900, −230, −765, 0.9) · hold ψ 0° · `galleryPts(S, every 30 m)` · turn ψ −12° · then `T` from (1,060, −380, −1,040). `ClearRoute(n)` sweeps the hull's sample points (+1.6 m) along the route poses and carves 6.5 m spheres where rock is within 6 m (`sweepCarve`), i.e. it does what the player does with a Tender. ### 2.11 How the playable space ends (U7) There is **no position clamp in any code path**. The v2 clamps (`10_player.js:390–391`, `06_carrier.js:521`, the swimmer's `SURFACE_Y − 2`) are not ported. A test asserts it (§10 M3). The space ends in four ways, each visible or announced: | Direction | What the player meets | Numbers | |---|---|---| | Sideways and around every void | **Rock.** Solid, drillable inside the claim box. | — | | Beyond the claim box | **Dense basalt**: glassy black rock with hexagonal jointing (material blend on `UV1.x`), drill rate falls to 1/8 over 40 m, then nothing cuts it. The drill shows "DENSE BASALT — NO PURCHASE" on the dash and sparks white. `ClaimDist(P)` = signed distance to the box X −330..1,560, Y −980..480, Z −1,700..+60. | ramp 0–40 m | | Down | **Crush depth.** Ratings: Tender 2,300 / 2,700 / 3,150 m, carrier 3,250 m. Damage `(0.6 + over/25)` HP/s, alarm every 1.6 s, warning on the dash from 50 m above the rating. The Abyss shaft narrows to nothing at Z −1,640 (depth 3,420 m). | §6.8 | | Up, above the rim | **The Shear**: a permanent current over the Throat's mouth. Marine snow streaks sideways, a low roar rises, the HUD shows "SHEAR CURRENT — ASCENT NOT AUTHORISED" from Z = +10, i.e. 15 m before any push. | below | Shear velocity field `FDescentShear::VelocityAt(P)` (m/s), added to the position integration of Tenders, swimmers and loose ore: ``` h = Z - 25; a = (Z > 25) ? (horizontal distance from the canyon centreline polyline) - 150 : 0 s = clamp(h / 4, 0, 40) + clamp(a / 10, 0, 40) dir = normalize(0.8 * (unit horizontal vector toward the centreline) + 0.6 * (0, 0, -1)) // straight down when over the centreline return dir * s ``` It reaches 10 m/s at Z = +65 and 25 m/s (more than a boosted, upgraded Dart) at Z = +125, so nothing leaves the Throat, and nothing stops dead either. The carrier's ballast-up authority is multiplied by `1 − smooth(−25, −5, Z)`; at Z > −25 the helm radio says "I will not rise into the Shear." ### 2.12 Ore deposits and loose ore Ore types *(v2)*: 0 ferrite (orange, 60 cr), 1 cobalt (cyan, 120 cr), 2 pearl (magenta, 260 cr). Deposits: capsule veins embedded in walls, built by the v2 algorithm (`buildDeposits`): pick a source point in open water, march a random ray to the wall (≤ 70 m), take the outward normal, place the capsule centre 0.35·r behind the surface along a random tangent. Half-length 2–6 m, radius 1.3–2.6 m, **yield `round(2 + half·radius·0.9)`** chunks, no two within 8 m. **Target 600 deposits** (v2: 120). Sources: half of all tube segments; 30 random points per chamber; one per 15 m of canyon; 5 above each shelf, 4 around each pinch, 8 per well, 3 per worm pocket. Type by height *(new bands)*, `r` uniform in [0, 1): | Z | ferrite | cobalt | pearl | |---|---|---|---| | > −370 | r < 0.92 | else | — | | −370 … −970 | r < 0.40 | r < 0.93 | else | | < −970 | r < 0.15 | r < 0.55 | else | Veins show in the rock through `UV0` (§7.8): coloured, faceted, glinting. **Crystals**: 3–7 instanced shards on each vein's exposed surface; carving within `DrillR` of one pops it into a loose chunk *(v2 `crystalsCheckCarve`)*. Loose ore chunks: pool of 160 instances (`UInstancedStaticMeshComponent`, faceted 0.34 m mesh built in code, emissive ore colour). Per step *(v2 `updateLoose`)*: suction first (§4.5); gravity −0.5 m/s²; drag `exp(−1.2·dt)`; `+ Shear`; collide with the density field at 0.35 m (reflect ×1.4, damp ×0.8). A swimmer can take one within 2.6 m (goes to the suit inventory). --- ## 3. Carrier (CANTICLE) All coordinates in this section are **carrier-local metres** (+X bow, +Y starboard, +Z up), converted from v2 with the rule of §0. The carrier has no user-made model yet, so it is a **procedural blockout built in code**. ### 3.1 Walking inside a moving vessel: the decision | Option | Verdict | |---|---| | A. `ACharacter` + `UCharacterMovementComponent` standing on the carrier's real collision (moving base) | **Chosen.** | | B. v2's carrier-local kinematic walker on hand-written walk rectangles | Rejected: placed objects (U1) would need their own collision code, and the user's future interior models would need hand-authored walk rectangles that match them. | | C. Static interior set somewhere else, exterior rendered separately | Rejected: the moon pool, the bridge windows and Tenders rising into the bay all need one continuous space. | Why A works here, with the settings that make it solid (`CharacterMovementComponent.h`): - The carrier is moved kinematically in sim step 2, **before** the character's movement tick (prerequisite, §1.4), with `SetActorLocationAndRotation(…, bSweep = false)`. `UpdateBasedMovement` then carries the capsule with the full base transform, including pitch (`CharacterMovementComponent.cpp:2531`). - `bFastAttachedMove = true`: the based move is applied without a sweep. This is safe because nothing outside the carrier has collision (terrain has none, §2.1), so no world geometry can be in the way. - `bStayBasedInAir = true`, `StayBasedInAirHeight = 300` uu, `bImpartBaseVelocityX/Y/Z = true`: a jump inside the hull stays with the hull. - `bIgnoreBaseRotation = false`: carrier yaw turns the character and the view with it. - Gravity stays world-down. The capsule stays upright while the deck pitches up to ±10° under it; with the 22.9° stair ramps the steepest floor is 33°, below `SetWalkableFloorAngle(50)`. - Placed objects, ore boxes and future interior meshes are ordinary collision attached to the carrier, so the character collides with them with no extra code. - When nobody is at the helm the carrier comes to all stop within seconds (§3.6), so most walking happens on a base that is at rest. Fallback if base-following jitter shows up in the `walk_moving` scenario (§10 M6): freeze carrier translation to all stop whenever the character is in `Foot` mode and not at the helm. It costs "walk around while under way", which the brief does not require. ### 3.2 Blockout construction and later replacement `UCarrierBlockoutComponent` builds everything from `FCarrierLayout` at `BeginPlay`: - **Hull**: a lathe of `HullRadius(x)` (64 segments around; rings every 2 m and along the nose ellipse) with the moon-pool rectangle left out of the belly, plus sail, pods, pylons, fins, shroud, keel and bow dome (dimensions in §3.3). One `UProceduralMeshComponent`, single-sided material, **no collision**. Because it is single-sided, rooms see the sea through their window openings; real hull models must have real windows. - **Rooms**: for each room one `UProceduralMeshComponent` `Room_` whose sections are boxes and prisms merged per material (floor, ceiling, walls with door and window holes, props), and whose collision is the same boxes passed once to `SetCollisionConvexMeshes` (one physics body per room). Collision profile: blocks `Pawn`, `Visibility` and the trace channel `Placement`. - **Stairs**: the collision is one convex ramp per flight; the 16 visual steps have no collision. - **Vessel-versus-vessel collision** does not use physics: Tenders and swimmers call `ICarrierHullShape::PushOut(LocalP, Radius, OutNormal)`. The blockout implements it analytically (v2 `subCollide` / `updateSwim`: radial push-out to `HullRadius(x) + Radius` for |x| < 62, except inside the moon-pool shaft Y 3.5..8.5, X −35..−23, Z < −7.5). - **Lighting channels**: sea = channel 0, carrier interior = channel 1. Interior lamps light channel 1 only and have `VolumetricScatteringIntensity = 0`; interior meshes, placeables, boxes and the character receive channel 1; the hull exterior and terrain receive channel 0; Tenders receive both; the flashlight lights both. - **Custom depth**: every interior primitive, fixture, placeable, ore box, pipe and docked Tender interior renders custom depth with **stencil 1 ("dry")** (§7.4). Replacement by real models later: `FRoomDef::OverrideMesh` and `FCarrierLayout::HullOverrideMesh` are asset paths, empty by default. When one is set, the builder spawns a `UStaticMeshComponent` with that mesh (authored in carrier-local coordinates) and skips the blockout for that room or for the hull; collision then comes from the mesh. Fixtures, pipes, lights, keep-out zones and the dry volume keep coming from the layout tables (`Anchors`), so gameplay does not change when the art does. ### 3.3 Hull, decks and rooms Hull *(v2)*: | Part | Dimensions | |---|---| | `HullRadius(x)` | `x > 40`: `12·sqrt(1 − ((x−40)/20)²)` (nose to x = 60); `abs(x) ≤ 40`: 12; `x < −40`: `12 − 5·smooth(40, 60, −x)` (taper to 7 at x = −60) | | Sail | x −10..30, y ±3, top z 18.2; mast (cosmetic) to z 22.8 at x 8 | | Thruster pods | radius 1.9, x −40..−20, at y ±13.2, z −3.5 (outer edge y ±15.1) | | Stern | cruciform fins 0.5 × 7 × 9 at radius 9.5, x −52; shroud ring radius 6.8 at x −61 | | Keel | 1.2 × 1.4 × 60 at z −12.2 (bottom −12.9), centred x 6; bow sonar dome at x 42, z −8.8 | | Moon pool | belly opening y 3.5..8.5, x −35..−23 (5 × 12 m); shaft walls z −11.6..−7.5; water surface z −8.3 | Decks (floor z): **D1 −7.5, D2 −3.0, D3 +1.5**. Room height 4.2; tall rooms 8.7. Doors are 2.4 m high. | Deck | Room (id) | x | y | Contents (fixtures are fixed; *placeable* items are start placements, §3.4) | |---|---|---|---|---| | D3 | BRIDGE (`bridge`) | 36..48 | −8..8 | Helm console and chair, helm fixture at (44.3, 0, 2.7); side consoles; chart table at (39.5, 0, 2.4). Windows: bow wall x 48, y −6.5..6.5, 1.6–3.8 m above the floor; both side walls x 42..46.5, 1.8–3.4 m | | D3 | QUARTERS A, B (`q1`, `q2`) | 28..36, 20..28 | −9..−1.6 | bed at (33, −7.3) and (25, −7.3); locker. Porthole in A: y −9, x 31..33 | | D3 | QUARTERS C, D (`q3`, `q4`) | 28..36, 20..28 | 1.6..9 | bed at (33, 7.3) and (25, 7.3). D is the emergency-lit cot room (red lamp) | | D3 | HABITATION (`hab`) | 6..20 | −9..−1.6 | *couch, table, shelf*; porthole y −9, x 12..17 | | D3 | FABRICATION (`fab`) | −8..6 | −9..−1.6 | *fabricator* at (−1, −6.5); workbench | | D3 | COMPONENTS (`comp`) | −8..6 | 1.6..9 | electronics racks with status lights | | D3 | UPPER DECK corridor (`corr3`) | −8..36 | −1.6..1.6 | ceiling pipes | | D2/D3 | STAIRWELL, tall (`stair`) | 6..20 | 1.6..9 | ramp y 5.25..8.55 from x 18.6 (D2) up to x 7.95 (D3); D3 landing x 6.05..7.95 | | D2 | GALLEY (`galley`) | 20..36 | −10..10 | 3 tables; **nutrient ports** at (24, −9.9), (24, 9.9), (32, −9.9), socket 1.45 m above the floor; **nutrient vat** at (21.2, 6.0); windows both sides x 24..32 | | D2 | CARGO BAY (`cargo`, v2 "storage") | 6..20 | −10..−1.6 | **packer** at (18.6, −5.0) with 4 outfeed slots at x 17.0, y −6.2 / −5.4 / −4.6 / −3.8; storage wall (5 lockers) along y −9.4; **Tethys terminal** at (9, −1.8) | | D2 | ORE PROCESSING (`refinery`) | −16..6 | −10..−1.6 | *refinery* at (−2, −6.5), hopper facing +y; conveyor prop | | D2 | ENGINE ROOM (`engine`) | −16..6 | 1.6..10 | reactor with pulsing core at (−4, 6); coolant tanks; pipes | | D2 | MID DECK corridor (`corr2`) | −18..20 | −1.6..1.6 | **ore pipe** and **bio pipe** under the ceiling (§6.3) | | D1/D2 | SUB BAY, tall (`bay`) | −40..−18 | −9..9 | D2 gallery x −19.8..−18; stairs y −8.75..−7.25 from x −19.7 (D2) down to x −30.6 (D1); D1 floor with the pool; carousel console at (−22, 1.6); ladder on the pool's inboard edge at (−29, 3.5); railings on three pool sides, gap x −30.3..−27.7 on the inboard side | Doors (deck: y-range, x-range): D3: bridge (−1.2..1.2, 35.6..36.4); q1 (−2..−1.2, 31..33); q2 (−2..−1.2, 23..25); q3 (1.2..2, 31..33); q4 (1.2..2, 23..25); hab (−2..−1.2, 12..14); stair landing (1.2..2, 6.3..8); fab (−2..−1.2, −2..0); comp (1.2..2, −2..0). D2: galley (−1.2..1.2, 19.6..20.4); cargo (−2..−1.2, 13..15); stair (1.2..2, 15..17); refinery (−2..−1.2, −6..−4); engine (1.2..2, −6..−4); bay gallery (−1.2..1.2, −18.4..−17.6). A cyan strip and a "DECK n" stencil (`UTextRenderComponent`) sit over every door. Room lamps (point lights on channel 1, no shadows, falloff exponent model §7.5, radius 11 m, tall rooms 16 m), position (x, y) under the ceiling, colour, relative intensity: | Room | Lamps | Colour | Int. | |---|---|---|---| | bridge | (41, 0) (44, −5) (44, 5) | #8fc8ff | 1.3 | | q1 / q2 / q3 | (32, −5) / (24, −5) / (32, 5) | #ffd2a0 | 1.1 / 1.1 / 1.0 | | q4 | (24, 5) | #ff3018 | 1.4 | | hab | (16, −5) (9, −5) | #ffe0b8 | 1.2 | | fab | (2, −5) (−5, −5) | #a8e8ff | 1.4 | | comp | (−1, 5) | #9affc8 | 0.9 | | corr3 | (30, 0) (18, 0) (6, 0) (−4, 0) | #d8e8ff | 0.9 | | stair | (13, 5) (10, 5 at 4.5 m) | #d8e8ff | 1.0 | | galley | (28, −5) (28, 5) (33, 0) | #ffd8a8 | 1.2 | | cargo | (13, −6) | #d0e0ff | 1.1 | | refinery | (0, −6) (−10, −6) | #ffb070 | 1.2 | | engine | (0, 6) (−10, 6) | #80c0ff | 1.0 × (0.85 + 0.15·sin 2.4t) | | corr2 | (14, 0) (2, 0) (−10, 0) | #d8e8ff | 0.9 | | bay | (−24, −5) (−34, 5) (−36, −5) (−22, 5) (−29, −8) | #c8e8ff | 1.5 | Exterior lights (V4a: the hull is a dark mass): red port nav (24, −12.1, 0.5); green starboard nav (24, 12.1, 0.5); white mast, 0.12 s flash every 2.2 s (8, 0, 22.9); white stern, slow pulse (−60.5, 0, 2); **one flood** spot at (46, 0, −9.2) aimed at local (130, 0, −30); a dim pool glow at (−29, 6, −12), radius 20. Nothing else. **Dry volume** `ADescentCarrier::IsDry(LocalP)`: `hypot(y, z) < HullRadius(x) − 0.3` and `|x| < 60`, and not (inside the pool rectangle and `z < −8.3`). Everything in §7.4 (no water effects), §6.9 (no fauna) and U5 (temperature) keys off this one function. **Temperature (U5)**: `Descent::AmbientTemperatureAt(WorldP)` returns **26.0 °C** when `IsDry`, otherwise the water temperature of §2.7. Every readout (HUD, Tender dash, helm dash, PDA status) calls it with its own reference point. On foot and at the helm the label is `CABIN 26.0 °C`; a Tender docked in the bay also reads 26.0. ### 3.4 Fixtures and start placeables Fixed (class `ADescentFixture`, cannot be picked up): helm, chart table, 4 beds, 3 nutrient ports, nutrient vat, packer, storage wall (capacity 60), Tethys terminal, carousel console, pool ladder, unload boom, hand intake hatch on the pipe riser (§6.3). Spawned through the build system at game start, so the player can pick them up and move them (U1): fabricator (−1, −6.5, D3, facing +y); refinery (−2, −6.5, D2, facing +y); couch (14, −7.8), table (14, −5.2), shelf (9, −2.0) in the habitation room; 3 galley tables with benches at (28, −5.5), (28, 0), (28, 5.5). ### 3.5 Sub bay, carousel and hoist (V5) | Item | Value | |---|---| | Hub | (−29, 0, 0), rotates about z; three arms 120° apart, radius 6; fixed cradles hang at z −1.0 *(v2)* | | Tender pose in a cradle | origin at the cradle, nose tangential; at the launch slot the nose points to the bow | | Launch slot | the arm at yaw +90° (pointing +y): cradle at **(−29, 6, −1)**, over the pool | | Rotation | console adds **+120° (clockwise seen from above)** at 0.35 rad/s (≈ 6 s) *(v2)*. Blocked while any Tender is out, while a hold is unloading, or while the hoist is moving | | Hoist *(new)* | the cradle at the launch slot is a hoist with two stops: STOWED z −1.0 and **BOARD z −6.3** (hull bottom 0.23 m above deck level, inside the pool mouth; canopy sill 1.25 m above the deck). It rests at BOARD whenever the carousel is idle. Travel 2 m/s (2.65 s). A rotation is: raise to STOWED → rotate → lower to BOARD | | Boarding | `E` within 4.5 m of the Tender at BOARD: canopy lid opens (0.5 s), camera moves to the seat (0.9 s) *(v2 timing)*; mode `Docked` | | Launch | `Space` in `Docked`: straight down from (−29, 6, −6.3) to DOCK_BELOW **(−29, 6, −16)** over 3.2 s (smoothstep), then control is handed over with velocity = carrier velocity + (0, 0, −1) | | Dock | allowed when the Tender is within ±7 m in y and ±9 m in x of the launch column and at local z −34..−11 *(v2)* and the hoist cradle is free: 1.6 s blend to DOCK_BELOW and carrier alignment, then 2.6 s rise to BOARD, attach, start unloading (§6.3) | | Pool fit | Kazah at the slot spans x −32.26..−25.27, y 4.36..7.64: 0.86 m to each long pool edge, 1.7 m and 2.7 m to the ends | | Clearance | farthest Tender corner from the hub: 8.5 m < 8.84 m (bay wall inside face); rotating Tenders span z −1.97..+0.55, 3.7 m above heads on D1, 0.7 m clear of the D2 gallery edge | Clearance test (automation, pure geometry): sweep the carousel through 360° in 1° steps with all three Tenders at STOWED; the minimum distance between any two Tender boxes (6.99 × 3.27 × 2.51 m around the model bounds) must be ≥ 2 m, and no box may intersect the bay walls, the D2 gallery slab, the stairs, the hub, the pipe riser (§6.3) or the unload boom in its stowed pose. Second sweep: the hoist from STOWED to BOARD to DOCK_BELOW must not intersect the pool rim or shaft. ### 3.6 Helm: the player pilots the carrier (V12, U4, U9) State (`UCarrierHelmComponent`): position, yaw, pitch, roll, velocity, yaw rate, throttle, ballast, strafe, hull 0..100, power 0..100, contacts, scrape 0..1. **Controls at the helm (U4: the mouse steers, as in a Tender, at the carrier's scale):** | Input | Effect | |---|---| | Mouse | moves the **aim point**: `AimYaw` (world heading) and `AimPitch`. 0.05° per pixel yaw, 0.03° per pixel pitch. `AimYaw` is clamped to within ±40° of the hull heading, `AimPitch` to ±10°. The hull turns toward the aim, so the aim reticle drifts back to the centre of the view as the hull catches up. | | W / S | throttle lever up / down at 1.5 per second, range −0.5..1. X = all stop. | | A / D | lateral thrusters (port / starboard) | | Space / C | ballast up / down | | Right mouse (hold) | free look (±80° yaw, −50..+35° pitch); the aim stays where it was | | V | next camera; R deep sonar ping; F floodlight; E leave the helm | **Dynamics** *(new constants for U9 and U4; structure of v2 `carrierPhysics`)*: | Quantity | Value | |---|---| | Forward speed | `v_target = throttle × 2.0 m/s`; `dv/dt = (v_target − v_fwd)/τ`, **τ = 1.2 s** → 90% of top speed 2.8 s after the lever is full, lever takes 0.67 s (v2: ≈ 20 s) | | Lateral | target ±0.6 m/s, τ = 1.5 s; unintended sideways velocity is damped at 0.6 s⁻¹ *(v2 keel damping)* | | Vertical | `v_z = v_fwd·sin(pitch) + ballast·1.3 m/s`, ballast response τ = 1.5 s | | Yaw | `err = wrap(AimYaw − Yaw)`; `ω_target = clamp(1.2·err, ±0.10 rad/s)`; `dω/dt = clamp((ω_target − ω)/0.8, ±α)`, `α = 0.03 + 0.01·abs(v_fwd)` rad/s² | | Pitch | target `AimPitch` when `abs(v_fwd) > 0.3`, else 0; response τ = 2.5 s; hard limit ±10° | | Roll | `−0.25·ω` rad (cosmetic) | | Collision | 250 hull sample points (list below) against `Density`. Contact when `d > −0.5`. Push = `−Gradient × (d + 0.5)`, summed, scaled `0.6/max(1, 0.35·contacts)`. Normal velocity removed ×0.9. Yaw torque `0.00006 × Σ(r × n)·pen`. Velocity × `exp(−1.5·dt)` while in contact *(v2)* | | Hull damage | `Σpen × (0.08 + 0.9·speed) × dt` *(v2)* | | Scrape | `damp(scrape, contacts ? min(1, 0.3·Σpen + 0.4·speed) : 0, 6, dt)` → audio | | Power | drain `0.05·abs(throttle)` %/s, regen 0.02 %/s; at 0 the throttle is forced to 0 *(v2)* | | Rating | **3,250 m** *(new)*; below it hull loses `(0.6 + over/25)` per second | | The Shear | ballast-up authority × `1 − smooth(−25, −5, Z)` (§2.11) | | Leaving the helm | all stop: throttle, strafe and ballast targets → 0, `AimYaw = Yaw`, `AimPitch = 0` | Hull sample points (carrier-local): rings at x = 59, 54, 48, 40, 30, 18, 6, −6, −18, −30, −40, −50, −58 with radius `HullRadius(x)` and 14 points each (8 where |x| > 50); sail at x = 28, 22, 16, 10, 4, −2, −8: (x, 0, 18.2) and (x, ±2.6, 15); pods (−21 / −30 / −39, ±15.1, −3.5); keel (30, 0, −12.9), (−10, 0, −12.9); stern (−55, 0, −15), (−55, 0, 16), (−55, ±14, 0). Hull consequences *(v2)*: below 30%: alarm every 6 s, interior lamps stutter (`sin 23t + sin 37.3t + sin(5.1t+1) > 1.2` → ×0.2), creaks double. At 0% ("CRIPPLED"): throttle capped at 0.25, ballast at ±0.4, radio line; no game over. Repair: fabricator recipe *Carrier Hull Patch* (+25). Cameras (`V` cycles; all are `UCameraComponent`s on the carrier, only one active): | # | View | Pose (carrier-local) | FOV | |---|---|---|---| | 0 | BRIDGE | eye at (44.2, 0, 3.15), first person, helm dash visible | 72° | | 1 | CHASE | (−85, 0, 28) looking at (30, 0, 0) | 60° | | 2 | TOP-DOWN | 130 m above, heading-up | 60° | | 3 | BOW CAM | (60, 0, −10.5), pitched −8.6° | 85° | | 4 | KEEL CAM | (−5, 0, −14.5), pitched −69° | 95° | In views 1–4 the water fog density is ×0.3, the hull gets a faint cyan rim (material parameter), eight feed lights (radius 48 m) at (70, 0, −3), (34, ±24, 0), (−12, ±24, −4), (12, 0, −26), (20, 0, 27), (−76, 0, 0) come on with the flood, and the HUD shows the **PROXIMITY** panel: 22 sensor rays (four around the hull at x = 50, 25, 0, −25, −50, plus bow and stern), 30 m range in 1.5 m steps, refreshed at 4 Hz; dots green > 10 m, amber, red < 4 m *(v2)*. **Helm dash (V6)**: in view 0 a `UWidgetComponent` over the helm console shows the same `SDescentDash` widget as the Tender (§4.6) fed by `UCarrierHelmComponent::MakeDashModel()`: title `CANTICLE · HELM`; left arc carrier HULL %; right arc POWER %; depth of the carrier origin with `RATING 3,250 m`; `CABIN 26.0 °C SPD x.x m/s`; `STORAGE u/c · THROTTLE n%`; `HDG nnn° · CONTACTS n`. In views 1–4 there is no dash, so the HUD vehicle panel shows the same values. Helm sonar: range 420 m, 4,200 rays, cooldown 4 s, origin (40, 0, −10) *(range raised from 320 m for the larger voids)*. --- ## 4. Tender (the Kazah "subtub") All coordinates in this section are **Tender-local metres** (+X nose, +Y starboard, +Z up, origin = the Blender origin). Model bounds: X −3.26..+3.73, Y ±1.635, Z −0.965..+1.545 (6.99 × 3.27 × 2.51 m). ### 4.1 Parts → components The export (`Tools/blender/export_kazah.py`, `Vehicles/KazahLayout.inl`) merges everything that never moves into `Body` and keeps the parts below separate, each with its pivot on its axis of motion. `AKazahTender` (exists) assembles them. Meshes live in `/Game/Vehicles/Kazah/SM_Kazah_`. | Part | Pivot (m) | Component and behaviour | |---|---|---| | `Body` | (0, 0, 0) | `UStaticMeshComponent`. Hull, tanks, propulsor ducts, guard hoops, stern fittings. Three child `UBoxComponent`s give it simple collision (blocks `Pawn`) so the player cannot walk through a Tender hanging in the bay: hull 6.6 × 1.9 × 2.0 at (0.2, 0, 0.2); two side boxes 3.0 × 0.9 × 0.9 at (−0.1, ±1.0, −0.4) | | `Cockpit_Interior` | (0, 0, 0) | seat and console. Renders custom-depth stencil 1 (dry). Hidden parts are not needed: it is small enough to stay visible in first person | | `Canopy_Lid` | (1.207, 0, −0.035) | hinge about the Y axis at its rear-bottom edge. Opens to −55° pitch in 0.5 s when boarding or leaving in the bay. `Canopy_Glass` is attached to it and moves with it | | `Canopy_Glass` | (1.207, 0, −0.035) | glass material `MI_KazahGlass` (§7.9) | | `Hatch_Lid` | (−0.288, 0, 1.338) | roof hatch, hinge at its aft edge. Opens to −70° in 0.4 s when the pilot leaves to swim or enters from the water | | `Rotor_Port`, `Rotor_Stbd` | (−2.830, −1.085, 0.130), (−2.832, 1.085, 0.130) | spin about X, counter-rotating. **RPM = 305 × thrust** (thrust 0..1.7; 0 when idle) *(v2: `spin += 32·thrust·dt`)* | | `Tool_Housing` | (1.972, ∓0.690, −0.775) | the one drill-plus-suction tool. See §4.5 for the side | | `Nose_Lens` | (3.687, 0, 0) | headlight lens; emissive when the lamp is on; the spot light sits 5 cm ahead of it | | `Chin_Sensor` | (3.136, −0.005, −0.451) | sonar transducer: ping origin; pulses cyan on a ping | Added in code (blockout, replaceable): drill bit (cone 0.18 m radius × 0.45 m, spins about X) and intake ring (torus radius 0.22 m) on the front face of the tool housing; a blinking top beacon; the launch clamps are part of the carrier. Fleet *(v2 specs, all three use the Kazah model)*: | Id | Name | Speed × | Drill radius | Drill rate × | Hold | Paint (overrides the "Pearl ceramic lacquer" colour) | |---|---|---|---|---|---|---| | BT-01 | TENDER | 1.0 | +0 | 1.0 | 8 | as modelled (0.638, 0.711, 0.674) | | BT-02 | DART | 1.3 | −0.4 | 0.85 | 6 | deep teal (0.03, 0.35, 0.33) | | BT-03 | MOLE | 0.8 | +0.9 | 1.4 | 12 | burnt orange (0.60, 0.12, 0.03) | ### 4.2 Derived stats: one place `FTenderStats UTenderFlightComponent::Stats() const` is the only source of every Tender number *(v2 `subStats`)*: | Stat | Formula | |---|---| | `HullMax` | `100 × (1 + 0.5·HullMk)` HP. Hull is stored in **absolute HP**; all damage is in HP; display `round(100·Hull/HullMax)` % | | `Rating` *(new)* | 2,300 m; 2,700 with Depth Module Mk I; 3,150 with Mk II | | `Capacity` | 100 energy units; 170 with Power Cell II. `Power` is stored as % of capacity; a drain of `u` units/s lowers it by `100·u/Capacity` %/s | | `Speed` | `9 × spec.Speed × (1 + 0.25·ThrustMk)` m/s | | `HoldCap` | `spec.Hold + 8·HoldMk` | | `BioCap` *(new)* | 4.0 FU (§6.9) | | `DrillR` | `3.0 + 0.7·DrillMk + spec.DrillR` m | | `DrillRate` | `9 × (1 + 0.5·DrillMk) × spec.DrillRate` | | `SonarRange` | 160 m; 260 m with the Sonar Module | | `SuctionRange`, `SuctionPull` | 13 m, 9; with Suction Mk II 22 m, 16 | ### 4.3 Flight model *(v2 `updateSub`, numbers unchanged)* | Quantity | Value | |---|---| | Steering | mouse sets yaw and pitch directly: 0.102° per pixel (v2 `SENS × 0.85`); pitch limit ±77° | | Roll | cosmetic: `damp(roll, −0.2·strafe + clamp(−0.004·lookDX, ±0.3), 3, dt)` rad | | Thrust | `wish = fwd·W/S + right·A/D + worldUp·Space/C`, normalised if longer than 1 | | Velocity | `vel = lerp(vel, wish·MaxS, 1 − exp(−1.8·dt))`; `MaxS = Speed × (boost ? 1.7 : 1) × hungerMul × (powered ? 1 : 0.3)` | | Position | `pos += (vel + Shear(pos))·dt`. **No clamp of any kind (U7).** | | Terrain collision | spheres below, two iterations: `pen = r + Density(c)`; if `pen > 0`: `pos −= Gradient·0.9·pen`; remove the into-rock velocity ×1.2; remember the largest normal speed as `hit` | | Carrier collision | `ICarrierHullShape::PushOut` with radius 2.0 m around the Tender origin, using velocity relative to the carrier | | Impact damage | if `hit > 4.5 m/s`: `(hit − 4.5) × 5` HP, shake, clank, toast "Hull impact −n%" | | Energy drains (units/s) | idle 0.1; boost 0.7; headlight 0.06; drill 1.3; laser 1.9; suction 0.35. Radio warning at 20% | | Crush | below `Rating`: `(0.6 + over/25)` HP/s, alarm and creak every 1.6 s; dash warning from 50 m above the rating | | Honest void | 14 HP/s while inside either honest void | | Hull 0 | death "implode" (§5.8) | | Docked | +6 %/s power, +4 HP/s hull | Collision spheres (centre, radius): nose (2.6, 0, 0.3) 1.0; cockpit (1.2, 0, 0.3) 1.15; centre (−0.3, 0, 0.2) 1.35; stern (−2.2, 0, 0.15) 1.1; propulsors (−2.55, ±1.09, 0.13) 0.55; tanks (0, ±1.0, −0.45) 0.5; tool (1.97, toolSide·0.69, −0.78) 0.3. ### 4.4 Lights | Light | Spec | |---|---| | Headlamp | spot at (3.74, 0, 0), aimed 3° down; exponent falloff (§7.5), radius 90 m, inner / outer cone 14.5° / 26.4°, colour #e6f6ff, start intensity 3.2, **casts shadows**, volumetric scattering 1.0. `F` toggles. Multiplied by the horror flicker (§8.3) | | Lens glow | `Nose_Lens` emissive 6.0 when on, 0.1 when off | | Cockpit glow | point light at the dash, radius 1.5 m, intensity 0.15, cyan, no volumetric scattering | | Top beacon | emissive blink 0.25 s every 1.6 s, no light component | ### 4.5 The single drill-plus-suction tool (U10) - **One tool.** `Tool_Housing` carries both the drill bit and the suction intake. There is no second arm. - **Side.** U10 says the left side stays free. In the model the housing sits on the **port (left)** side (pivot Y −0.690). The Tender therefore mounts it **mirrored on the starboard side**: component location (1.972, +0.690, −0.775), relative scale (1, −1, 1). One constant, `Descent::Tune::Tender::ToolSide = +1`, switches it back (see the open question in §11). The port hardpoint (1.972, −0.690, −0.775) is an empty named socket `Hardpoint_Port`; the state dump reports `hardpoints: { starboard: "drill_suction", port: "empty" }`. - **Use.** Hold **LMB: drill**. Hold **RMB: suction**. If both are held the drill wins (one motor). `T` swaps the drill bit for the laser head once the Laser Cutter is fabricated. The player drills a vein, the ore breaks loose, then sucks the loose chunks in with the same tool. - **Aim.** A ray from the cockpit camera along the view direction. The tool head swivels up to 25° toward the hit point. | Function | Rule *(v2 numbers)* | |---|---| | Drill | reach 16 m. On a hit: `Carve(hit + dir·0.8, DrillR, DrillRate, dt)` (hardness inside `Carve`); cleanup timer (§2.5); bit spins 38 rad/s; a pale stream from the tool tip to the hit; sparks and rock debris every 0.04 s; a glow light (colour (1.0, 0.8, 0.55), radius 9 m) at the hit; shake 0.12 | | Laser head | reach 28 m; `Carve(hit + dir·0.5, max(1.6, 0.6·DrillR), 1.6·DrillRate, dt)`; red beam; shake 0.04 | | Suction | nozzle = tool tip. Every loose chunk within `SuctionRange` and inside the cone (`cos ≥ 0.55` to the view direction, or closer than 3 m) is accelerated toward the nozzle by `SuctionPull × (1.2 − d/SuctionRange)`. Within 1.6 m it is captured into the hold. If the hold is full the chunk is pushed back (velocity × −0.3) and "HOLD FULL — dock to unload" shows every 2 s. The same cone captures fauna into the bio-tank (§6.9). Motes stream into the intake ring; the ring glows cyan | | Basalt | drilling dense basalt (§2.11): white sparks, no carve, dash line "DENSE BASALT — NO PURCHASE" | ### 4.6 Cockpit, dashboards and the cargo display (V6, U11) - **Eye**: (1.95, 0, 0.72), FOV 72°. In `Sub` mode the view is rigid to the hull (the mouse steers). In `Docked` the mouse looks around: yaw ±75°, pitch −52..+40°. Near clip plane 5 cm (`r.SetNearClipPlane 5`). - **Canopy** from inside: `MI_KazahGlass` with a 0.22 fresnel rim, faint smudges and condensation *(v2)*. - **Main dash** `DashMain`: a `UWidgetComponent` hosting `SDescentDash`, 1.10 × 0.45 m, centre at eye + (0.75, 0, −0.34), pitched back 14°; draw size 1024 × 420 px; manual redraw at 8 Hz and whenever the rounded depth changes. Layout *(v2 §4.3, pixel coordinates)*: | Element | Place | Content | |---|---|---| | Title | top centre | `BT-01 TENDER · DRILL` (or `· LASER`, `· SUCTION` while sucking) | | Left arc | centre (210, 230), radius 130, 270° sweep, 14 px thick, round caps, 13% track, glow | **HULL** %; cyan, red below 30% | | Right arc | centre (814, 230), radius 130 | **POWER** %; amber, red below 20% | | Depth | centre top, 86 px bold | `2,061 m`; below it `RATING 2,300 m`, or `!! CRUSH DEPTH 2,300 m` in red | | Depth bar | x 362..662, y 208 | fill = depth between 1,740 and 3,400 m; red tick = rating | | Line 1 | under the bar | `TEMP xx.x °C SPD x.x m/s` | | Line 2 | bottom | `HOLD u/c · BIO b/4.0`; red when the hold is full | - **Cargo display (U11)** `DashCargo`: a second `UWidgetComponent` hosting `SCargoDisplay`, 0.30 × 0.30 m, centre at eye + (0.60, 0.36, −0.30), yawed 35° toward the pilot; draw size 512 × 512 px. It is a **picture of the hold**, not a number: - a cut-away tank outline divided into `HoldCap` cells, 4 columns, filled bottom-up **in the order the chunks arrived**; each filled cell shows a faceted chunk in its ore colour (ferrite #ff9a3c, cobalt #3fe6ff, pearl #ff5ad8) with its symbol (Fe, Co, Pe); empty cells are inset wells; - a new chunk drops in from the top over 0.3 s; - header `CARGO 5/8`; footer three chips `Fe 3 · Co 2 · Pe 0`; - a vertical green bar on the right for the bio-tank, `BIO 1.2/4.0`; - full hold: the outline turns red and pulses, header `HOLD FULL`; - while unloading (§6.3) the cells empty top-down in step with the pieces leaving through the boom. - **One source of truth.** `FTenderDashModel UTenderFlightComponent::MakeDashModel()` builds every value once; `SDescentDash`, `SCargoDisplay`, the HUD vehicle panel and the `tenders` state section all read that model. Depth uses the Tender origin in every sub mode (§5.9). - The mirrored position on the port side, eye + (0.60, −0.36, −0.30), is reserved (`DashAux`, hidden). ### 4.7 Abandoned Tenders A Tender the pilot left to swim stays where it is (zero thrust, velocity damped by `exp(−1.8·dt)`, still subject to the Shear and to collision). It can be re-entered within 4.5 m. The carousel stays locked until it is docked again. --- ## 5. Player ### 5.1 The pawn `ADescentCharacter` is the only possessed pawn for the whole game. Vehicles are never possessed: the player controller routes intents to them (§5.7) and switches the view target. While piloting, the character is attached to the vehicle, hidden, with movement mode `MOVE_None` and no collision. | Item | Value | |---|---| | Capsule | radius 0.34 m, half-height 0.90 m; eye 1.65 m above the feet *(v2)* | | Camera | `UCameraComponent` on the character, FOV 72°, uses control rotation; view bob as v2 (`0.045·sin(2·bob)` vertical) | | Lighting channels | 0 and 1 | ### 5.2 On foot (inside the carrier only) `UDescentCharacterMovement`, `MOVE_Walking` / `MOVE_Falling`, with the moving-base settings of §3.1. | Quantity | Value *(v2)* | |---|---| | Walk / sprint | 2.9 / 4.8 m/s, × `hungerMul` (`nutrition < 20 ? lerp(0.55, 1, nutrition/20) : 1`); carrying a box: 2.3 m/s, no sprint | | Acceleration | braking and acceleration tuned so velocity follows `damp(k = 12)` | | Jump | 4.4 m/s × `sqrt(hungerMul)`; gravity scale 1.12 (11 m/s²) | | Step height / walkable angle | 0.35 m / 50° | | Footstep sound | every 1.35 m walked | | Pool | stepping or jumping through the gap in the pool railing: when the feet pass the water plane (carrier z −8.3) inside the pool rectangle → `Swim`, entry velocity `(0, 0, min(−2.5, 0.6·v_z))`, splash | ### 5.3 Swimming Custom movement mode `MOVE_Custom / Swim` implemented in `PhysCustom` (`CharacterMovementComponent.h:2007`). The capsule is replaced by a 0.45 m sphere test against the density field; the move itself is a `SafeMoveUpdatedComponent` sweep so physics objects (Tender collision boxes) still block. | Quantity | Value *(v2 §4.13)* | |---|---| | Speed | 2.8 m/s; sprint 4.2 (O₂ drain ×1.4); propulsor 7.5 (hold LMB or Q with it selected); all × `hungerMul` | | Response | `vel = lerp(vel, wish·MaxS, 1 − exp(−2.2·dt))`; `wish` from the full view rotation plus Space / C on world up | | Sink | 0.12 m/s² downward | | Position | `pos += (vel + Shear(pos))·dt`. **No clamp.** | | Terrain | `PushOutSphere(pos, 0.45)` twice per step | | Carrier hull | `ICarrierHullShape::PushOut(pos, 0.6)`; inside the moon-pool shaft the head cannot rise above the water plane − 0.35 m | | Tenders | pushed out to 2.2 m from an undocked Tender's origin | | O₂ | −1 per second (−1.4 sprinting or gliding). With the head within 0.6 m of the pool surface inside the shaft: +20 per second. At 0: health −9 per second, "DROWNING" | | Breath sound | every 3.4 s; every 1.8 s below 10 s of O₂ | | Ladder | `E` in the shaft, within 1.6 m of the water surface and y < 5.3: 1.6 s climb, then `Foot` at carrier-local (−29, 2.6, −7.5) | | Leave a Tender | `E` in `Sub` away from the dock: the roof hatch opens, the swimmer appears at Tender-local (1.2, −2.2, 0.2) | | Enter a Tender | `E` within 4.5 m of an undocked Tender | | Take loose ore or a polyp | `E` within 2.6 m → suit inventory | ### 5.4 Interaction `UDescentInteractionComponent` keeps a registry of `IDescentInteractable`s (fixtures, placeables, boxes, Tenders, sockets). Each late step it scores every candidate with the v2 rule: within `GetRange()` of the eye and `dot(view, toTarget) ≥ GetMinDot()`; `score = dot − 0.05·distance`; the best one is the focus. The HUD prompt shows `[E]