Merge origin/master into consumable-supply

This commit is contained in:
Meta Vens 2026-10-10 14:05:56 +02:00
commit cf6503bf4e
49 changed files with 3229 additions and 99 deletions

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using GameCult.Caching;
using GameCult.Caching.MessagePack;
@ -13,9 +14,15 @@ public static class AetheriaStores
// Attaches (hydrates) the catalog read-only unless catalogWritable, then the run and player stores when given.
// Throws when a [CultGlobal] type routed to the catalog has no record. The one exemption is a writable catalog with
// no records at all, a seed store being authored from nothing; a populated catalog is held to it however it opens.
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false)
// The registry is the process-wide one unless the caller scopes it (the test host does, so its own document types never
// become catalog globals); the global check above is judged against whichever registry opened the cache.
public static CultCache Open(string catalogPath, string runPath = null, string playerPath = null, bool catalogWritable = false,
CultDocumentRegistry registry = null)
{
var cache = new CultCache();
// A read-only open of an absent catalog would hydrate an empty game; a shipped player missing its catalog stops here.
if (!catalogWritable && !File.Exists(catalogPath))
throw new FileNotFoundException($"Catalog {catalogPath} does not exist; a read-only open never invents one.", catalogPath);
var cache = new CultCache(registry);
try
{
cache.AddBackingStore(new SingleFileMessagePackBackingStore(catalogPath, !catalogWritable), CatalogTypes);

View file

@ -187,6 +187,8 @@ public interface IPowerConsumer
// superseded draft quoted -- checked directly here rather than trusted from that document.
Union(39, typeof(TargetingSystemData)),
Union(40, typeof(MineLayerData)),
Union(41, typeof(ThrottleLockData)),
Union(42, typeof(VapourDumpData)),
JsonObject(MemberSerialization.OptIn)]
public abstract class BehaviorData
{

View file

@ -160,17 +160,18 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
var diff = entity.Position.xz - Entity.Position.xz;
var dist = length(diff);
var obscuration = Entity.Zone.Obscuration(Entity.Position.xz, entity.Position.xz);
float previous, next;
Entity.EntityInfoGathered.TryGetValue(entity, out previous);
if (!_pingedEntities.Contains(entity) && dist < _pingRadius)
{
_pingedEntities.Add(entity);
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), Evaluate(_data.PingBoost), dist, dt, true, obscuration));
}
else
{
var angle = acos(dot(forward, normalize(diff)));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false));
next = saturate(previous + Gain(entity.Visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), dist, dt, false, obscuration));
}
next *= 1 - ItemManager.GameplaySettings.TargetInfoDecay * dt;
//Context.Log($"{entity.Name} visibility {(int)(previous * 100)}% -> {(int)(next * 100)}%");
@ -182,12 +183,14 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
// Mining Cut 3 (docs/mining-cut-refresh.md): the one detection gain rule -- the info one observation of a
// target adds. A ping adds `visibility × sensitivity × PingBoost × distance` once; a passive tick adds
// `visibility × sensitivity × curve(angle/π) × dt / distance`. `response` is PingBoost for a ping and the
// sensitivity curve at the bearing for a passive tick. The entity loop above and the chunk query
// (Entity.ChunkInfo, through PassiveRate) both call this; nothing else multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping) =>
ping
// sensitivity curve at the bearing for a passive tick. `obscuration` is the fraction of the signal that
// survives the vapour clouds on the sight line (Zone.Obscuration, 1f with none), scaling both. The entity
// loop above and the chunk query (Entity.ChunkInfo, through PassiveRate) both call this; nothing else
// multiplies these terms.
public static float Gain(float visibility, float sensitivity, float response, float distance, float dt, bool ping, float obscuration) =>
(ping
? visibility * sensitivity * response * distance
: visibility * sensitivity * response * dt / distance;
: visibility * sensitivity * response * dt / distance) * obscuration;
// The passive gain per second this sensor draws from a target of `visibility` at the planar `position`:
// Gain over one second, with the bearing measured from this sensor's own facing exactly as Execute does.
@ -195,7 +198,8 @@ public class Sensor : Behavior, IEventBehavior, IPowerConsumer
{
var diff = position - Entity.Position.xz;
var angle = acos(dot(Direction.xz, normalize(diff)));
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false);
return Gain(visibility, Evaluate(_data.Sensitivity), _data.SensitivityCurve.Evaluate(angle / PI), length(diff), 1f, false,
Entity.Zone.Obscuration(Entity.Position.xz, position));
}
// An upper bound on sensitivity x response over every bearing, so PassiveRate(v, p) <= v x this / distance

View file

@ -0,0 +1,35 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using MessagePack;
using Newtonsoft.Json;
// Presence marks the lock: Entity.ThrottleLocked is true while an active effect carries one, and Ship.Update
// then reads full forward intent in place of MovementDirection. The behaviour holds no state and does nothing
// itself; turning stays with the pilot.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class ThrottleLockData : BehaviorData
{
public override Behavior CreateInstance(EquippedItem item)
{
return new ThrottleLock(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new ThrottleLock(this, item);
}
}
public class ThrottleLock : Behavior
{
public ThrottleLock(ThrottleLockData data, EquippedItem item) : base(data, item)
{
}
public ThrottleLock(ThrottleLockData data, ConsumableItemEffect item) : base(data, item)
{
}
public override bool Execute(float dt) => true;
}

View file

@ -0,0 +1,61 @@
using MessagePack;
using Newtonsoft.Json;
// A coolant dump that becomes a vapour cloud: the first time it runs it vents one cloud around its venter,
// drifting with the venter's velocity. Radius, Opacity and Lifetime are evaluated through the host, so role
// quality applies as for any other stat. Later runs vent nothing.
[Inspectable, MessagePackObject, JsonObject(MemberSerialization.OptIn), RuntimeInspectable]
public class VapourDumpData : BehaviorData
{
[Inspectable, JsonProperty("radius"), Key(1), RuntimeInspectable]
public PerformanceStat Radius = new PerformanceStat();
// How much of a sight line's signal a fresh cloud removes, in [0, 1].
[Inspectable, JsonProperty("opacity"), Key(2), RuntimeInspectable]
public PerformanceStat Opacity = new PerformanceStat();
// Seconds from venting until the cloud has faded away and is removed.
[Inspectable, JsonProperty("lifetime"), Key(3), RuntimeInspectable]
public PerformanceStat Lifetime = new PerformanceStat();
public override Behavior CreateInstance(EquippedItem item)
{
return new VapourDump(this, item);
}
public override Behavior CreateInstance(ConsumableItemEffect item)
{
return new VapourDump(this, item);
}
}
public class VapourDump : Behavior
{
private VapourDumpData _data;
private bool _vented;
public VapourDump(VapourDumpData data, EquippedItem item) : base(data, item)
{
_data = data;
}
public VapourDump(VapourDumpData data, ConsumableItemEffect item) : base(data, item)
{
_data = data;
}
public override bool Execute(float dt)
{
if (_vented) return true;
_vented = true;
Entity.Zone.Vent(new VapourCloud
{
Body = new KinematicBody { Position = Entity.Position.xz, Velocity = Entity.Velocity },
Radius = Evaluate(_data.Radius),
Opacity = Evaluate(_data.Opacity),
Lifetime = Evaluate(_data.Lifetime),
Venter = Entity
});
return true;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 961a74683c574807a300eb26694f206d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -872,6 +872,9 @@ public abstract class Entity
initializable.Initialize();
}
// Derived, never written: true iff an active consumable effect carries a ThrottleLock.
public bool ThrottleLocked => _activeConsumables.Any(e => e.Behaviors.OfType<ThrottleLock>().Any());
public ConsumableItemEffect FindActiveConsumable(ConsumableItemData data)
{
return _activeConsumables.FirstOrDefault(ac => ac.Data == data);

View file

@ -130,8 +130,8 @@ public static class FireControl
// arc, so the gun can fire along it. Direction: where a round from this gun travels and where its barrel points --
// the subject's intercept when it bears, else the arc-clamped aim. A chunk subject bears through its pose and
// flies the aim, as an untargeted round (Subject is null for it). Derived on every read and never stored: turrets
// traverse instantly in the simulation. Traverse speed belongs to the link (ruling arc-and-traverse-on-the-link),
// which will make this stateful behind the same signature. It never prices; HitProbability is the one price.
// traverse instantly in the simulation. Traverse is the weapon's Tracking (ruling mount-arc-gun-traverse), priced in
// TrackingRate; this holds no slew state, and a rig's slew is presentation. It never prices; HitProbability is the one price.
public static GunSolution Solution(Weapon weapon, Entity shooter, TargetRef subject)
{
var bears = false;

View file

@ -233,27 +233,29 @@ public class Ship : Entity
if (_active && !_exitingWormhole && !_enteringWormhole)
{
RecalculateThrust();
// The lock replaces intent here, once; player and agent intent both pass through this one read.
var move = ThrottleLocked ? float2(0, 1) : MovementDirection;
foreach (var thruster in _allThrusters) thruster.Axis = 0;
var rightThrusterTorqueCompensation = abs(RightStrafeTotalTorque) / RightStrafeTorqueThrusters.Count;
foreach (var thruster in _rightThrusters)
{
var thrust = 0f;
thrust += MovementDirection.x;
thrust += move.x;
if (RightStrafeTorqueThrusters.Contains(thruster))
thrust -= MovementDirection.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust -= move.x * (rightThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
var leftThrusterTorqueCompensation = abs(LeftStrafeTotalTorque) / LeftStrafeTorqueThrusters.Count;
foreach (var thruster in _leftThrusters)
{
var thrust = 0f;
thrust += -MovementDirection.x;
thrust += -move.x;
if (LeftStrafeTorqueThrusters.Contains(thruster))
thrust += MovementDirection.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thrust += move.x * (leftThrusterTorqueCompensation / (abs(thruster.Torque) * thruster.Thrust));
thruster.Axis = thrust;
}
foreach (var thruster in _forwardThrusters) thruster.Axis += MovementDirection.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -MovementDirection.y;
foreach (var thruster in _forwardThrusters) thruster.Axis += move.y;
foreach (var thruster in _reverseThrusters) thruster.Axis += -move.y;
foreach (var thruster in _clockwiseThrusters) thruster.Axis += Turn;
foreach (var thruster in _counterClockwiseThrusters) thruster.Axis += -Turn;

View file

@ -17,6 +17,22 @@ public sealed class ShipAuthoring
[Key(2)] public string ModelAsset;
[Key(3)] public List<ShipAnchor> Anchors = new List<ShipAnchor>();
[Key(4)] public List<ShipPolyline> SchematicLines = new List<ShipPolyline>();
// The moving parts of the rig, presentation only (ruling rig-is-presentation): no joint value feeds the simulation.
[Key(5)] public List<ShipJoint> Joints = new List<ShipJoint>();
}
// A rig joint: a bone of the model's armature. Axes are three floats per degree of freedom, a unit vector in Ship Root
// space at rest (glTF axes, +Y up); Min and Max are that degree of freedom's limits in degrees.
[MessagePackObject]
public sealed class ShipJoint
{
// The bone's aetheria.id, which is also its GLB node id.
[Key(0)] public string Id;
// The nearest joint above this one in the model, or null for a root joint.
[Key(1)] public string Parent;
[Key(2)] public float[] Axes;
[Key(3)] public float[] Min;
[Key(4)] public float[] Max;
}
[MessagePackObject]
@ -25,13 +41,15 @@ public sealed class ShipAnchor
// Stable semantic identity. HullData.Hardpoints[].Transform names this ID for mounts.
[Key(0)] public string Id;
// One of: map-icon, hull-collider, shield, tractor, thruster-emitter, weapon-mount,
// weapon-muzzle, radiator-mesh, or articulation (a pivot, never a mount).
// weapon-muzzle, or radiator-mesh.
[Key(1)] public string Role;
// Stable node ID in the compiled model, never a Blender display name.
[Key(2)] public string ModelNodeId;
// Only child roles use ParentId; e.g. a muzzle names its weapon hardpoint.
[Key(3)] public string ParentId;
[Key(4)] public int Order;
// On a weapon-mount anchor: the id of the nearest joint above its node in the model; null or empty is a fixed mount.
[Key(5)] public string Joint;
}
// Evaluated or baked Grease Pencil stroke coordinates in Blender's right-handed Z-up
@ -53,8 +71,7 @@ public static class ShipAuthoringStore
// ShipAnchor.Role's vocabulary.
private static readonly HashSet<string> Roles = new HashSet<string>(StringComparer.Ordinal)
{
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh",
"articulation"
"map-icon", "hull-collider", "shield", "tractor", "thruster-emitter", "weapon-mount", "weapon-muzzle", "radiator-mesh"
};
// The anchor role a visible hardpoint's mount plays, or null for an internal hardpoint, which has no anchor.
@ -272,6 +289,7 @@ public static class ShipAuthoringStore
foreach (var hardpoint in mounts.Values.Where(hardpoint => IsWeapon(hardpoint.Type)))
if (!anchors.Any(anchor => anchor.Role == "weapon-muzzle" && anchor.ParentId == hardpoint.Transform))
throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} needs at least one muzzle anchor.");
ValidateRig(ship, anchors, mounts, nodeIds);
foreach (var line in ship.SchematicLines ?? new List<ShipPolyline>())
{
@ -291,4 +309,111 @@ public static class ShipAuthoringStore
throw new InvalidOperationException($"{ship.Id}: schematic line color must be finite RGBA.");
}
}
// A degree of freedom counts as yaw when its axis lies within 5 degrees of Ship Root up (glTF +Y).
private static readonly float YawAxisCos = MathF.Cos(5f * MathF.PI / 180f);
// The joints, which anchors ride them, and whether each driven joint's chain can sweep the arc its guns state. The
// joints are presentation, so this judges only that the rig could point the gun where the hardpoint says it may fire.
private static void ValidateRig(ShipAuthoring ship, List<ShipAnchor> anchors, Dictionary<string, HardpointData> mounts, HashSet<string> nodeIds)
{
var joints = ship.Joints ?? new List<ShipJoint>();
var byId = new Dictionary<string, ShipJoint>(StringComparer.Ordinal);
foreach (var joint in joints)
{
if (joint == null || string.IsNullOrWhiteSpace(joint.Id) || byId.ContainsKey(joint.Id))
throw new InvalidOperationException($"{ship.Id}: joint IDs must be present and unique.");
if (nodeIds.Contains(joint.Id))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} is also an anchor's model node.");
byId.Add(joint.Id, joint);
}
foreach (var joint in joints)
{
var dofs = joint.Min?.Length ?? 0;
if (dofs < 1 || joint.Max?.Length != dofs || joint.Axes?.Length != 3 * dofs)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} needs one or more degrees of freedom: Axes holds three values each, Min and Max one each.");
for (var dof = 0; dof < dofs; dof++)
{
var (x, y, z) = (joint.Axes[3 * dof], joint.Axes[3 * dof + 1], joint.Axes[3 * dof + 2]);
var (min, max) = (joint.Min[dof], joint.Max[dof]);
if (!float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z) || !float.IsFinite(min) || !float.IsFinite(max))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} axes and limits must be finite.");
if (MathF.Abs(MathF.Sqrt(x * x + y * y + z * z) - 1f) > 1e-3f)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} axis {dof} must be a unit vector.");
if (min < -180f || max > 180f || min > max)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} limits {dof} must satisfy -180 <= Min <= Max <= 180.");
}
if (!string.IsNullOrEmpty(joint.Parent) && !byId.ContainsKey(joint.Parent))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} has an unknown parent joint.");
}
foreach (var joint in joints)
{
var step = joint;
for (var depth = 0; !string.IsNullOrEmpty(step.Parent); depth++)
{
if (depth >= joints.Count)
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} is on a parent cycle.");
step = byId[step.Parent];
}
}
foreach (var anchor in anchors.Where(anchor => !string.IsNullOrEmpty(anchor.Joint)))
{
if (anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not ride a joint (follow-up gimballed-thrusters).");
if (anchor.Role != "weapon-mount")
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} may not name a joint; only a weapon mount rides one.");
if (!byId.ContainsKey(anchor.Joint))
throw new InvalidOperationException($"{ship.Id}: weapon mount {anchor.Id} names an unknown joint.");
}
var ridden = anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
.Select(anchor => (Hardpoint: mounts[anchor.Id], Joint: anchor.Joint)).ToArray();
foreach (var (hardpoint, _) in ridden)
if (!(hardpoint.FiringArc > 0f))
throw new InvalidOperationException($"{ship.Id}: weapon hardpoint {hardpoint.Transform} rides a joint, so it must state its firing arc above 0.");
foreach (var group in ridden.GroupBy(mount => mount.Joint, StringComparer.Ordinal))
{
var lead = group.First().Hardpoint;
foreach (var (hardpoint, _) in group)
if (hardpoint.Rotation != lead.Rotation || hardpoint.FiringArc != lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: weapon hardpoints {lead.Transform} and {hardpoint.Transform} ride joint {group.Key} and must share Rotation and FiringArc.");
var reach = YawReach(ship, group.Key);
if (reach < lead.FiringArc)
throw new InvalidOperationException($"{ship.Id}: joint {group.Key} chain reaches {reach} degrees of yaw, short of the {lead.FiringArc} degree firing arc of {lead.Transform}.");
}
}
// The yaw a driven joint's chain can sweep: the summed range of every degree of freedom whose axis is yaw, capped at a turn.
private static float YawReach(ShipAuthoring ship, string jointId)
{
var joints = ship.Joints.ToDictionary(joint => joint.Id, StringComparer.Ordinal);
var reach = 0f;
foreach (var joint in RigChains.Chain(ship, jointId).Select(id => joints[id]))
for (var dof = 0; dof < joint.Min.Length; dof++)
if (MathF.Abs(joint.Axes[3 * dof + 1]) >= YawAxisCos)
reach += joint.Max[dof] - joint.Min[dof];
return MathF.Min(reach, 360f);
}
}
// A rig's driven joints and the chains that move them. A driven joint is one a weapon-mount anchor names. Its chain runs
// from the joint up through its ancestors and stops before the nearest ancestor that is itself driven (that joint has
// its own gun, and its pose is that gun's business), listed root-first. The judge and the plan both read this one rule.
public static class RigChains
{
public static HashSet<string> Driven(ShipAuthoring ship) =>
new HashSet<string>(ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint)).Select(anchor => anchor.Joint),
StringComparer.Ordinal);
public static string[] Chain(ShipAuthoring ship, string jointId)
{
var joints = ship.Joints.ToDictionary(joint => joint.Id, StringComparer.Ordinal);
var driven = Driven(ship);
var chain = new List<string> { jointId };
for (var parent = joints[jointId].Parent; !string.IsNullOrEmpty(parent) && !driven.Contains(parent); parent = joints[parent].Parent)
chain.Add(parent);
chain.Reverse();
return chain.ToArray();
}
}

View file

@ -45,7 +45,7 @@ public static class ShipModCatalog
// named. A collision (with the shipped catalog, or with another package's hull name) excludes every package involved,
// since neither has a claim to the name. The derived catalog holds the shipped catalog plus the included packages.
// Unsafe inputs and outputs (a missing catalog, a derived file that would replace a source) still throw: nothing is composed.
public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot)
public static Composition Compose(string shippedCatalog, string outputCatalog, string modsRoot, CultDocumentRegistry registry = null)
{
var source = Path.GetFullPath(shippedCatalog);
var output = Path.GetFullPath(outputCatalog);
@ -84,7 +84,7 @@ public static class ShipModCatalog
Directory.CreateDirectory(workspace);
File.Copy(source, temporary);
Composition composition;
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true))
using (var cache = AetheriaStores.Open(temporary, catalogWritable: true, registry: registry))
{
// Collisions are judged against the shipped records alone, before any package is written, so the
// verdict does not depend on directory order. CultCache indexes names per concrete type and lets the
@ -153,14 +153,15 @@ public static class ShipModCatalog
// The one game-side choice of catalog: the shipped file when no package is installed or none survives composition,
// otherwise the derived file, composed afresh from the shipped one and the surviving packages. Recomposed every
// call, so the derived file is never stale and never an authority. Excluded packages come back named, for the menu.
public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot)
public static (string Catalog, Exclusion[] Excluded) ResolveCatalog(string shippedCatalog, string derivedCatalog, string modsRoot,
CultDocumentRegistry registry = null)
{
if (!Directory.Exists(modsRoot) ||
!Directory.GetDirectories(modsRoot).Any(directory => File.Exists(Path.Combine(directory, "ship.cc"))))
return (shippedCatalog, Array.Empty<Exclusion>());
Composition composition;
// A failure of the composition itself (an unwritable derived path, say) is still a mod problem, never a reason not to boot.
try { composition = Compose(shippedCatalog, derivedCatalog, modsRoot); }
try { composition = Compose(shippedCatalog, derivedCatalog, modsRoot, registry); }
catch (Exception error) { return (shippedCatalog, new[] { new Exclusion { Package = "(all mods)", Reason = error.Message } }); }
return (composition.Included.Length == 0 ? shippedCatalog : derivedCatalog, composition.Excluded);
}
@ -171,7 +172,7 @@ public static class ShipModCatalog
throw new InvalidOperationException($"{path}: ship ID must match its package directory name.");
var modelPath = Path.GetFullPath(Path.Combine(directory, ship.ModelAsset));
// Containment is ShipAuthoringStore.Validate's job: it refuses rooted and `..` model paths before this line.
if (!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.OrdinalIgnoreCase))
if (!File.Exists(modelPath) || !string.Equals(Path.GetExtension(modelPath), ".glb", StringComparison.Ordinal))
throw new InvalidOperationException($"{ship.Id}: model asset must name an existing GLB inside its package.");
var modelNodes = ReadNodeIds(modelPath);
foreach (var anchor in ship.Anchors)
@ -182,6 +183,7 @@ public static class ShipModCatalog
foreach (var anchor in ship.Anchors)
if ((anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh") && !modelNodes[anchor.ModelNodeId].HasMesh)
throw new InvalidOperationException($"{ship.Id}: {anchor.Role} anchor {anchor.Id} needs a mesh, but model node {anchor.ModelNodeId} has none.");
BindRig(ship, modelNodes);
CultRecordRefs.Validate(hull);
return new Package
{
@ -190,9 +192,43 @@ public static class ShipModCatalog
};
}
// GLB is the xenos asset boundary. Only its node extras, and whether each node names a mesh, are read here; geometry
// belongs to the runtime importer.
public static Dictionary<string, (uint Index, bool HasMesh)> ReadNodeIds(string path)
// The rig the record states must be the rig the model has: every joint is a node, each joint's Parent and each mount's
// Joint is the nearest joint above its node, ancestors without an id being walked through by ReadNodeIds, and a thruster or radiator
// mesh sits under no joint (follow-up gimballed-thrusters).
private static void BindRig(ShipAuthoring ship, Dictionary<string, (uint Index, bool HasMesh, int Parent)> nodes)
{
var joints = new HashSet<string>((ship.Joints ?? new List<ShipJoint>()).Select(joint => joint.Id), StringComparer.Ordinal);
var idAt = nodes.ToDictionary(node => (int)node.Value.Index, node => node.Key);
// Parent is already the nearest ancestor that carries an id, so the walk up is the chain of those.
string JointAbove(string nodeId)
{
// glTF forbids cycles; the bound keeps a malformed model from hanging the binder.
var parent = nodes[nodeId].Parent;
for (var steps = 0; parent >= 0 && steps <= nodes.Count; steps++, parent = nodes[idAt[parent]].Parent)
if (joints.Contains(idAt[parent])) return idAt[parent];
return null;
}
foreach (var joint in ship.Joints ?? new List<ShipJoint>())
{
if (!nodes.ContainsKey(joint.Id))
throw new InvalidOperationException($"{ship.Id}: model has no node with aetheria.id={joint.Id} for a joint.");
if (!string.Equals(JointAbove(joint.Id) ?? "", joint.Parent ?? "", StringComparison.Ordinal))
throw new InvalidOperationException($"{ship.Id}: joint {joint.Id} must name the nearest joint above its model node as its Parent.");
}
foreach (var anchor in ship.Anchors.Where(anchor => anchor.Role == "weapon-mount" || anchor.Role == "thruster-emitter" || anchor.Role == "radiator-mesh"))
{
var above = JointAbove(anchor.ModelNodeId);
if (string.Equals(above ?? "", anchor.Joint ?? "", StringComparison.Ordinal)) continue;
throw new InvalidOperationException(anchor.Role == "weapon-mount"
? $"{ship.Id}: weapon mount {anchor.Id} must name the nearest joint above its model node as its Joint."
: $"{ship.Id}: {anchor.Role} anchor {anchor.Id} sits under a joint in the model (follow-up gimballed-thrusters).");
}
}
// GLB is the xenos asset boundary. Only its node extras, whether each node names a mesh, and its nearest identified ancestor's node index
// (-1 for none) are read here; geometry belongs to the runtime importer.
public static Dictionary<string, (uint Index, bool HasMesh, int Parent)> ReadNodeIds(string path)
{
using var stream = File.OpenRead(path);
using var reader = new BinaryReader(stream);
@ -203,17 +239,29 @@ public static class ShipModCatalog
if (reader.ReadUInt32() != 0x4E4F534A || chunkLength > stream.Length - stream.Position)
throw new InvalidOperationException($"{path}: missing GLB JSON chunk.");
var json = JObject.Parse(Encoding.UTF8.GetString(reader.ReadBytes((int)chunkLength)));
var ids = new Dictionary<string, (uint Index, bool HasMesh)>(StringComparer.Ordinal);
var nodes = json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>();
var ids = new Dictionary<string, (uint Index, bool HasMesh, int Parent)>(StringComparer.Ordinal);
var nodes = (json["nodes"]?.OfType<JObject>() ?? Enumerable.Empty<JObject>()).ToArray();
var parents = Enumerable.Repeat(-1, nodes.Length).ToArray();
for (var parent = 0; parent < nodes.Length; parent++)
foreach (var child in nodes[parent]["children"]?.Select(token => (int)token) ?? Enumerable.Empty<int>())
if (child >= 0 && child < nodes.Length) parents[child] = parent;
uint index = 0;
foreach (var node in nodes)
{
var id = (string)node["extras"]?["aetheria.id"];
if (string.IsNullOrEmpty(id)) { index++; continue; }
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null)))
if (!ids.TryAdd(id, (index, node["mesh"] != null && node["mesh"].Type != JTokenType.Null, IdentifiedAncestor(parents, nodes, (int)index))))
throw new InvalidOperationException($"{path}: duplicate GLB node aetheria.id={id}.");
index++;
}
return ids;
}
private static int IdentifiedAncestor(int[] parents, JObject[] nodes, int index)
{
var parent = parents[index];
for (var steps = 0; parent >= 0 && steps <= nodes.Length; steps++, parent = parents[parent])
if (!string.IsNullOrEmpty((string)nodes[parent]["extras"]?["aetheria.id"])) return parent;
return -1;
}
}

View file

@ -15,6 +15,10 @@ public sealed class ShipModPlan
public string[] Radiators;
// Each weapon mount (role weapon-mount) and its muzzle anchors, by Order then id.
public (string Mount, string[] Muzzles)[] Weapons;
// One entry per driven joint (one a weapon mount names), by its first weapon in hull order: the joint, its chain
// root-first (RigChains), and the lead mount (the joint's first weapon in hull order) with that mount's first muzzle.
// Presentation only: the assembler rigs these and nothing here reaches the simulation.
public (string Joint, string[] Chain, string LeadMount, string LeadMuzzle)[] Rigs;
// Maps a pair Validate accepts; every rule about which anchor may play which part is Validate's, so this refuses nothing.
public static ShipModPlan Build(HullData hull, ShipAuthoring visual)
@ -26,6 +30,11 @@ public sealed class ShipModPlan
var muzzles = visual.Anchors.Where(anchor => anchor.Role == "weapon-muzzle")
.OrderBy(anchor => anchor.Order).ThenBy(anchor => anchor.Id, StringComparer.Ordinal).ToArray();
var jointOf = visual.Anchors.Where(anchor => anchor.Role == "weapon-mount" && !string.IsNullOrEmpty(anchor.Joint))
.ToDictionary(anchor => anchor.Id, anchor => anchor.Joint, StringComparer.Ordinal);
var leads = hardpoints.Where(hardpoint => jointOf.ContainsKey(hardpoint.Transform)).GroupBy(hardpoint => jointOf[hardpoint.Transform], StringComparer.Ordinal)
.Select(group => (Joint: group.Key, Lead: group.First().Transform));
return new ShipModPlan
{
MapIcon = Only("map-icon"),
@ -36,7 +45,9 @@ public sealed class ShipModPlan
Radiators = Mounts(HardpointType.Radiator),
Weapons = hardpoints.Where(hardpoint => ShipAuthoringStore.IsWeapon(hardpoint.Type)).Select(hardpoint => (
hardpoint.Transform,
muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray()
muzzles.Where(muzzle => muzzle.ParentId == hardpoint.Transform).Select(muzzle => muzzle.Id).ToArray())).ToArray(),
Rigs = leads.Select(rig => (rig.Joint, RigChains.Chain(visual, rig.Joint), rig.Lead,
muzzles.First(muzzle => muzzle.ParentId == rig.Lead).Id)).ToArray()
};
}
}

View file

@ -0,0 +1,105 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/. */
using System;
using CultMath;
/// <summary>
/// Turns intent (which way to go, which way to turn) into every thruster's throttle, whatever the thrusters' actual
/// strength and position. One per ship. A column is one thruster's effect per unit throttle: x starboard acceleration,
/// y forward acceleration, z clockwise yaw rate in rad/s. Throttles are bounded to [0,1].
///
/// One <see cref="BoundedLeastSquares"/> solve per call over the n throttles and four error variables (the unwanted or
/// missing translation on each half-axis, paid for linearly). Yaw is a heavily weighted row, so a hold is exact and a
/// turn is served first; the linear translation cost means the actuator with the least translation per unit of turn
/// serves first and the next joins only as it saturates. Nothing classifies a column as attitude or drive: the onset
/// at which a second actuator joins a turn falls out of the columns.
/// </summary>
public sealed class ThrustAllocator
{
const float TranslationWeight = 100f;
const float YawWeight = 1000f;
const float ErrorCostWeight = .3f;
const float ErrorCostOffset = 10f;
const float Ridge = .1f;
const float ErrorMax = 2f;
const double KktRelativeTolerance = 1e-12;
const int ErrorVariables = 4;
const int FixedRows = 3 + ErrorVariables;
int _columns = -1;
float[] _a, _b, _lo, _hi, _x, _workspace;
public void Allocate(ReadOnlySpan<float3> columns, float2 move, float turn, Span<float> throttle)
{
var n = columns.Length;
if (n != _columns) Resize(n);
for (var i = 0; i < n; i++) throttle[i] = 0;
// Nothing asked, nothing fired: that is a fact about the intent, decided before the solve. The solver's warm
// start is left as the last solved point, which is where the next non-zero call resumes.
if (Intent(move, turn, 0) == 0 && Intent(move, turn, 1) == 0 && Intent(move, turn, 2) == 0) return;
var vars = n + ErrorVariables;
var rows = FixedRows + n;
// Rows 0 and 1 are the translation axes (starboard, forward), row 2 the yaw.
for (var axis = 0; axis < 3; axis++)
{
float plus = 0, minus = 0;
for (var i = 0; i < n; i++)
{
var c = Component(columns[i], axis);
if (c > 0) plus += c;
else if (c < 0) minus -= c;
}
var extreme = Math.Max(plus, minus);
var intent = Intent(move, turn, axis);
var weight = axis == 2 ? YawWeight : TranslationWeight;
// Intent is a fraction of the half-axis extreme it points into.
var demand = intent >= 0 ? intent * plus : intent * minus;
var scale = extreme > 0 ? weight / extreme : 0f;
var row = axis * vars;
for (var i = 0; i < n; i++) _a[row + i] = Component(columns[i], axis) * scale;
_b[axis] = demand * scale;
if (axis < 2)
{
var errorWeight = extreme > 0 ? weight : 0f;
_a[row + n + 2 * axis] = -errorWeight;
_a[row + n + 2 * axis + 1] = errorWeight;
}
}
// A linear cost on each error variable, as w*e = -w*kappa, and a ridge on each throttle.
for (var k = 0; k < ErrorVariables; k++)
{
_a[(3 + k) * vars + n + k] = ErrorCostWeight;
_b[3 + k] = -ErrorCostWeight * ErrorCostOffset;
}
for (var i = 0; i < n; i++) _a[(FixedRows + i) * vars + i] = Ridge;
var status = BoundedLeastSquares.Solve(rows, vars, _a, _b, _lo, _hi, _x, _workspace, out _, KktRelativeTolerance);
if (status == BoundedLeastSquaresStatus.InvalidInput) return;
for (var i = 0; i < n; i++) throttle[i] = _x[i];
}
static float Intent(float2 move, float turn, int axis) =>
Math.Min(Math.Max(axis == 0 ? move.x : axis == 1 ? move.y : turn, -1f), 1f);
static float Component(float3 c, int axis) => axis == 0 ? c.x : axis == 1 ? c.y : c.z;
void Resize(int n)
{
_columns = n;
var vars = n + ErrorVariables;
var rows = FixedRows + n;
_a = new float[rows * vars];
_b = new float[rows];
_lo = new float[vars];
_hi = new float[vars];
_x = new float[vars];
_workspace = new float[BoundedLeastSquares.WorkspaceLength(vars)];
for (var i = 0; i < vars; i++) _hi[i] = i < n ? 1f : ErrorMax;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5b0e3f7a9c2d4e61a8f4d3c7b1e90a26
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -0,0 +1,32 @@
using CultMath;
using static CultMath.math;
using float2 = CultMath.float2;
// A vented vapour cloud: a free-floating disc that thins every sensor sight line through it. Not an Entity,
// never priced or rolled, never saved. Zone.Vent is the only adder and Zone's cloud step the only remover;
// Zone.Obscuration is the one reader detection uses.
public sealed class VapourCloud
{
public FloatingBodyId Id;
public KinematicBody Body;
public double VentedAt;
public float Lifetime;
public float Radius;
// The opacity at the moment of venting; it fades linearly to nothing over Lifetime.
public float Opacity;
public Entity Venter;
// A cloud with no lifetime divides by zero; saturate maps the NaN and the infinity to nothing left.
public float OpacityAt(double now) =>
saturate(Opacity) * saturate(1f - (float) (now - VentedAt) / Lifetime);
// Whether the segment a-b reaches the disc: the closest point of the segment to the centre lies within the
// radius. A cloud past either end of the segment touches it only if it is near that end.
public bool Touches(float2 a, float2 b)
{
var ab = b - a;
var length2 = lengthsq(ab);
var t = length2 > 0f ? saturate(dot(Body.Position - a, ab) / length2) : 0f;
return lengthsq(Body.Position - (a + ab * t)) <= Radius * Radius;
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ced45a9738974452b0065b57ee527e8a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -20,6 +20,10 @@ public class Zone
// Laid mines, in lay order. Zone owns their existence: Lay is the one adder, the mine step the one remover,
// and every removal is a blast. Runtime state, never saved.
public ReactiveCollection<Mine> Mines = new ReactiveCollection<Mine>();
// Vented vapour clouds, in vent order. Zone owns their existence: Vent is the one adder, StepClouds the one
// remover, and Obscuration the one reader that detection uses. Runtime state, never saved.
public ReactiveCollection<VapourCloud> Clouds = new ReactiveCollection<VapourCloud>();
public Dictionary<CultRecordKey, BodyData> Planets = new Dictionary<CultRecordKey, BodyData>();
public Dictionary<CultRecordKey, Planet> PlanetInstances = new Dictionary<CultRecordKey, Planet>();
@ -225,6 +229,7 @@ public class Zone
foreach (var entity in Entities.ToArray()) entity.Update(deltaTime);
StepMines(deltaTime);
StepClouds(deltaTime);
// Cut 3: after every entity has had its chance to fire this tick, age and resolve the shots that
// firing queued. A shot fired this tick with a flight time shorter than CommitHorizon commits and
@ -238,6 +243,39 @@ public class Zone
Mines.Add(mine);
}
public void Vent(VapourCloud cloud)
{
cloud.Id = NextBodyId();
cloud.VentedAt = _time;
Clouds.Add(cloud);
}
// The fraction of a sight line's signal that survives the clouds between `a` and `b`: the product of
// (1 - opacity now) over every cloud whose disc the segment touches, so 1f when none does. Derived from the
// live clouds at each call and stored nowhere; Sensor.Gain is where it enters detection.
public float Obscuration(float2 a, float2 b)
{
var through = 1f;
for (var i = 0; i < Clouds.Count; i++)
{
var cloud = Clouds[i];
if (cloud.Touches(a, b)) through *= 1f - cloud.OpacityAt(_time);
}
return through;
}
// After StepMines: each cloud drifts as a KinematicBody on the zone's force, as a mine does, and is removed
// once its lifetime has run out. Nothing else removes a cloud.
private void StepClouds(float deltaTime)
{
var settings = _itemManager.GameplaySettings;
foreach (var cloud in Clouds.ToArray())
{
cloud.Body.Step(deltaTime, GetForce(cloud.Body.Position), settings);
if (cloud.VentedAt + cloud.Lifetime <= _time) Clouds.Remove(cloud);
}
}
// After the entity loop and before FireControl.Step, in lay order: each mine drifts as a KinematicBody, is
// triggered by the first eligible hull its armed disc touches, and detonates through FireControl.Detonate at
// its trigger plus fuse delay or at its lifetime, whatever its layer does (ruling mine-owner-leash). The