Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions src/SiegeFX.Core/Actors/TriggerRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,18 @@ void EvaluateInstance(TriggerInstance trig, TriggerContext ctx)
// TriggerRow rarely exceeds 4 groups in shipped data; a small dict is fine.
var firedGroups = new HashSet<int>();
bool anySatisfied = false;
bool gated = RowHasMessageGate(row);
for (int c = 0; c < row.Conditions.Count; c++)
{
var cond = row.Conditions[c];
// A "wait_for_every_message" volume condition is no trigger
// of its own: it is checked when the row's message arrives.
if (gated && IsMessageGate(cond)) continue;
if (EvaluateCondition(trig, cond, ctx, ref state))
{
if (gated && IsMessageCondition(cond) && !MessageGatesHold(trig, row, ctx, ref state))
continue; // message consumed with the gate shut — DS1 drops it

Bump(_conditionHits, cond.Verb);
firedGroups.Add(cond.Group);
anySatisfied = true;
Expand Down Expand Up @@ -388,6 +395,43 @@ void EvaluateInstance(TriggerInstance trig, TriggerContext ctx)
}
}

static bool IsMessageCondition(TriggerCall cond) =>
cond.Verb.Equals("receive_world_message", StringComparison.OrdinalIgnoreCase);

/// <summary>Boundary mode <c>wait_for_every_message</c> /
/// <c>wait_for_first_message</c> (the last argument of the volume verbs):
/// the condition gates the row's receive_world_message instead of firing
/// it. The multiplayer_world teleporter buttons ship exactly this —
/// receive_world_message("we_req_activate") + party_member_within_node(...,
/// "wait_for_every_message") = "the button was pressed AND someone is on
/// the platform" — and evaluated as two independent sources, stepping onto
/// the platform launched it with no press.</summary>
static bool IsMessageGate(TriggerCall cond)
{
if (IsMessageCondition(cond) || cond.Args.Count == 0) return false;
var mode = cond.Args[^1].Trim().Trim('"');
return mode.Equals("wait_for_every_message", StringComparison.OrdinalIgnoreCase)
|| mode.Equals("wait_for_first_message", StringComparison.OrdinalIgnoreCase);
}

static bool RowHasMessageGate(TriggerRow row)
{
bool msg = false, gate = false;
foreach (var c in row.Conditions)
{
if (IsMessageCondition(c)) msg = true;
else if (IsMessageGate(c)) gate = true;
}
return msg && gate;
}

bool MessageGatesHold(TriggerInstance trig, TriggerRow row, TriggerContext ctx, ref TriggerRowState state)
{
foreach (var c in row.Conditions)
if (IsMessageGate(c) && !EvaluateCondition(trig, c, ctx, ref state)) return false;
return true;
}

void ScheduleAction(TriggerInstance trig, TriggerRow row, TriggerCall act, TriggerContext ctx)
{
var totalDelay = row.Delay + act.CallDelay;
Expand Down
44 changes: 44 additions & 0 deletions src/SiegeFX.Core/Assets/AnimationRuntime.cs
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,50 @@ public static void ComputeSkinMatrices(AspMesh mesh, PrsAnimation anim, float ti
skinOut[i] = mesh.InverseBindMatrices[i] * worldAnim[i];
}

/// <summary>SC-PROP-REST-POSE — the whole-mesh offset a skinned prop's
/// rest clip puts on it: the root (parentless) bone's skin matrix at
/// <paramref name="timeSec"/>. DS1 draws a GO through its animation, so
/// a constant root track that differs from the mesh bind moves the entire
/// prop. Every shipped lever_glb_06 is the case: its press clips key
/// Bone01 one unit down mesh -Z from its bind, and the placements are
/// authored for that posed mesh (the MP teleporter buttons sit 1.15 above
/// the platform; the dm_r8a wall buttons 1.05 in front of the wall).
/// Drawn at bind, the button hung a unit off its surface.
///
/// Only a root the clip HOLDS still counts: a root that travels during
/// the clip (statue_cav_rise_01 rises out of the floor) is motion, not an
/// authoring offset, and which end of it DS1 starts at is a state
/// question this helper doesn't answer. Returns false (offset = identity)
/// when the clip keys no root bone, the root moves, or it matches the
/// bind.</summary>
public static bool TryRootRestOffset(AspMesh mesh, PrsAnimation clip, out Matrix4x4 offset)
{
offset = Matrix4x4.Identity;
if (mesh.BoneCount == 0 || mesh.InverseBindMatrices.Length != mesh.BoneCount) return false;
int root = -1;
for (var i = 0; i < mesh.BoneCount; i++)
if (mesh.BoneParents[i] < 0) { root = i; break; }
if (root < 0) return false;
var map = GetBoneMap(mesh, clip);
if (map[root] < 0 || clip.BoneKeys[map[root]] is null) return false;
var m = ComputeSkinMatrices(mesh, clip, 0f)[root];
if (!NearlyEqual(m, ComputeSkinMatrices(mesh, clip, clip.AnimLength * 0.5f)[root])
|| !NearlyEqual(m, ComputeSkinMatrices(mesh, clip, clip.AnimLength)[root]))
return false;
if (NearlyEqual(m, Matrix4x4.Identity)) return false;
offset = m;
return true;
}

static bool NearlyEqual(in Matrix4x4 a, in Matrix4x4 b)
{
const float Eps = 1e-3f;
return MathF.Abs(a.M11 - b.M11) < Eps && MathF.Abs(a.M12 - b.M12) < Eps && MathF.Abs(a.M13 - b.M13) < Eps
&& MathF.Abs(a.M21 - b.M21) < Eps && MathF.Abs(a.M22 - b.M22) < Eps && MathF.Abs(a.M23 - b.M23) < Eps
&& MathF.Abs(a.M31 - b.M31) < Eps && MathF.Abs(a.M32 - b.M32) < Eps && MathF.Abs(a.M33 - b.M33) < Eps
&& MathF.Abs(a.M41 - b.M41) < Eps && MathF.Abs(a.M42 - b.M42) < Eps && MathF.Abs(a.M43 - b.M43) < Eps;
}

/// <summary>Compose the per-bone animated world matrices (mesh-local) for the given
/// clip at <paramref name="timeSec"/>. Returns one matrix per ASP bone: the bone's
/// animated parent-space local transform walked up the hierarchy. Used by weapon/
Expand Down
158 changes: 157 additions & 1 deletion src/SiegeFX.Core/Assets/ElevatorStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,100 @@ public sealed record ElevatorDef(
string Moving1ActionInfo, string Moving2ActionInfo,
string Moving1Message, string Moving2Message,
uint Moving1Scid, uint Moving2Scid,
uint Level2Scid);
uint Level2Scid)
{
/// <summary>SC-ELEVATOR-INSTANT — set for <c>elevator_instant_4s_1c</c>
/// (the multiplayer_world town teleporters). The base record then
/// describes car 1 only: Connect1/Connect2 are its level-1 docks at
/// location 1/2, so stop 1 = car 1 at location 1.</summary>
public InstantElevatorSpec? Instant { get; init; }
}

/// <summary>One docking pose of an instant-elevator car: the car's
/// <see cref="CarDoorId"/> mates with <see cref="ConnectDoorId"/> on
/// <see cref="ConnectGuid"/> (same door alignment as every elevator stop).</summary>
public readonly record struct InstantDock(uint ConnectGuid, int ConnectDoorId, int CarDoorId);

/// <summary>SC-ELEVATOR-INSTANT — the hub teleporter (component
/// <c>elevator_instant_4s_1c.skrit</c>). TWO cars (<c>elevator_node</c> and
/// <c>e2_elevator_node</c>) and two locations (<c>connect_node_location1/2</c>,
/// long shaft nodes), each with a level 1 (the platform at the floor) and a
/// level 2 (up the shaft). The cars always sit at opposite locations. A ride:
/// both cars rise to level 2 of their own location, then swap — each pops to
/// level 2 of the other location (SRequestNodeConnection, no travel) — and
/// sink to level 1 there. The multiplayer_world towns each pair a car with
/// one in the new_hub island region; the town and hub gizmos describe the
/// same two cars from either end (<c>other_elevator</c>).
/// Lists index by location − 1.</summary>
public sealed record InstantElevatorSpec(
uint Car2NodeGuid,
IReadOnlyList<InstantDock> Docks,
IReadOnlyList<float> AscendSeconds,
IReadOnlyList<float> DescendSeconds,
IReadOnlyList<string> ToLevel2ActionInfo,
IReadOnlyList<string> ToLevel1ActionInfo,
string UpEffect,
string DownEffect,
uint OtherElevatorScid,
uint GizmoNodeGuid,
Vector3 GizmoLocalPosition)
{
/// <summary>Car 0 = <c>elevator_node</c>, car 1 = <c>e2_elevator_node</c>.</summary>
public InstantDock Dock(int car, int location, int level) =>
Docks[InstantElevatorPlan.DockIndex(car, location, level)];

public uint CarGuid(uint car1Guid, int car) => car == 0 ? car1Guid : Car2NodeGuid;
}

/// <summary>One car's moves in one instant-elevator ride: rise lv1 → lv2 at
/// <see cref="AscendLocation"/>, pop to lv2 of <see cref="ArriveLocation"/>,
/// sink to lv1 there.</summary>
public readonly record struct InstantCarLeg(
int Car,
int AscendLocation,
int ArriveLocation,
string AscendActionInfo, // locationN_moving_to_lv2_actioninfo of the rise
string ArriveActionInfo); // locationN_moving_to_lv1_actioninfo of the arrival

public readonly record struct InstantRide(
int FromStop, int ToStop, float AscendSeconds, float DescendSeconds,
InstantCarLeg Car1, InstantCarLeg Car2)
{
public InstantCarLeg Leg(int car) => car == 0 ? Car1 : Car2;

/// <summary>The skrit's action delay: <c>min(duration / 2, 0.5)</c> into
/// each move, the party members still on the car get the actioninfo.</summary>
public static float ActionDelay(float duration) => MathF.Min(duration / 2f, 0.5f);
}

/// <summary>SC-ELEVATOR-INSTANT — the state machine of
/// <c>elevator_instant_4s_1c.skrit</c> as data. Stop s = car 1 at location s,
/// car 2 at the other one.</summary>
public static class InstantElevatorPlan
{
public static int DockIndex(int car, int location, int level) => car * 4 + (location - 1) * 2 + (level - 1);

public static int CarLocation(int car, int stop) => car == 0 ? stop : 3 - stop;

/// <summary>The ride leaving <paramref name="fromStop"/>. Location1_start
/// rises for <c>duration_location1_moving_to_lv2</c>; its swap lands in
/// Location2_descending (<c>duration_location2_moving_to_lv1</c>).
/// Location2_start mirrors it. Each car's rise takes the actioninfo of
/// its own location's lv2 move, its arrival that of the location it
/// lands at (OnTimer 1–4 in the skrit).</summary>
public static InstantRide Ride(InstantElevatorSpec spec, int fromStop)
{
int from = fromStop == 2 ? 2 : 1, to = 3 - from;
InstantCarLeg Leg(int car)
{
int rise = CarLocation(car, from), land = CarLocation(car, to);
return new InstantCarLeg(car, rise, land,
spec.ToLevel2ActionInfo[rise - 1], spec.ToLevel1ActionInfo[land - 1]);
}
return new InstantRide(from, to,
spec.AscendSeconds[from - 1], spec.DescendSeconds[to - 1], Leg(0), Leg(1));
}
}

/// <summary>SC-STAIRWELL — the crypt rotating-stairwell mechanism
/// (<c>elevator_hidden_stairwell</c> / <c>_act_deact</c>): N stair-segment
Expand Down Expand Up @@ -140,6 +233,12 @@ public static (IReadOnlyList<ElevatorDef> Defs, IReadOnlyList<string> Diagnostic
continue;
}

if (Attr(comp, "connect_node_location1") is not null || Attr(comp, "connect_node_location2") is not null)
{
if (ParseInstant(p, comp, regionPath, car, extraDiags) is { } inst) defs.Add(inst);
continue;
}

uint shared = Hex(Attr(comp, "connect_node"));
uint c1 = Hex(Attr(comp, "connect_node_level1"));
uint c2 = Hex(Attr(comp, "connect_node_level2"));
Expand Down Expand Up @@ -174,6 +273,63 @@ public static (IReadOnlyList<ElevatorDef> Defs, IReadOnlyList<string> Diagnostic
return (defs, extraDiags);
}

/// <summary>SC-ELEVATOR-INSTANT — <c>elevator_instant_4s_1c</c>. Defaults
/// are the skrit's: car doors 1, connect doors 0 (= unset), rise 2s,
/// sink 10s, no actioninfo, effects hub_plate_up / hub_plate_down.</summary>
static ElevatorDef? ParseInstant(ActorInstance p, GasNode comp, string regionPath, uint car1, List<string> diags)
{
uint car2 = Hex(Attr(comp, "e2_elevator_node"));
uint loc1 = Hex(Attr(comp, "connect_node_location1"));
uint loc2 = Hex(Attr(comp, "connect_node_location2"));
if (car2 == 0 || loc1 == 0 || loc2 == 0)
{
diags.Add($"{regionPath}: instant elevator 0x{p.Scid:X8} missing e2_elevator_node / connect_node_location1/2 — skipped");
return null;
}
var docks = new InstantDock[8];
for (int c = 0; c < 2; c++)
for (int loc = 1; loc <= 2; loc++)
for (int lv = 1; lv <= 2; lv++)
{
string pre = c == 0 ? "" : "e2_";
int connectDoor = Int(Attr(comp, $"{pre}connect_door_location{loc}_level{lv}"), 0);
int carDoor = Int(Attr(comp, $"{pre}elevator_door_location{loc}_level{lv}"), 1);
if (connectDoor == 0)
{
diags.Add($"{regionPath}: instant elevator 0x{p.Scid:X8} has no {pre}connect_door_location{loc}_level{lv} — skipped");
return null;
}
docks[InstantElevatorPlan.DockIndex(c, loc, lv)] = new InstantDock(loc == 1 ? loc1 : loc2, connectDoor, carDoor);
}
float Dur(string name, float fallback) => Float(Attr(comp, name), fallback);
var spec = new InstantElevatorSpec(
Car2NodeGuid: car2,
Docks: docks,
AscendSeconds: new[] { Dur("duration_location1_moving_to_lv2", 2f), Dur("duration_location2_moving_to_lv2", 2f) },
DescendSeconds: new[] { Dur("duration_location1_moving_to_lv1", 10f), Dur("duration_location2_moving_to_lv1", 10f) },
ToLevel2ActionInfo: new[] { Str(Attr(comp, "location1_moving_to_lv2_actioninfo")), Str(Attr(comp, "location2_moving_to_lv2_actioninfo")) },
ToLevel1ActionInfo: new[] { Str(Attr(comp, "location1_moving_to_lv1_actioninfo")), Str(Attr(comp, "location2_moving_to_lv1_actioninfo")) },
UpEffect: StrOr(Attr(comp, "up_effect"), "hub_plate_up"),
DownEffect: StrOr(Attr(comp, "down_effect"), "hub_plate_down"),
OtherElevatorScid: Hex(Attr(comp, "other_elevator")),
GizmoNodeGuid: p.Placement.NodeGuid,
GizmoLocalPosition: p.Placement.LocalPosition);
var a1 = spec.Dock(0, 1, 1);
var a2 = spec.Dock(0, 2, 1);
return new ElevatorDef(
Scid: p.Scid,
TemplateName: p.TemplateName,
RegionPath: regionPath,
CarNodeGuid: car1,
Connect1Guid: a1.ConnectGuid, Connect1DoorId: a1.ConnectDoorId, CarDoor1Id: a1.CarDoorId,
Connect2Guid: a2.ConnectGuid, Connect2DoorId: a2.ConnectDoorId, CarDoor2Id: a2.CarDoorId,
DurationSeconds: spec.AscendSeconds[0] + spec.DescendSeconds[1],
Moving1ActionInfo: "", Moving2ActionInfo: "",
Moving1Message: "we_req_activate", Moving2Message: "we_req_activate",
Moving1Scid: 0, Moving2Scid: 0, Level2Scid: 0)
{ Instant = spec };
}

static string? Attr(GasNode node, string name)
{
foreach (var a in node.Attributes)
Expand Down
73 changes: 73 additions & 0 deletions src/SiegeFX.Core/Assets/PropRestPose.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
using System.Numerics;

namespace SiegeFX.Core.Assets;

/// <summary>SC-PROP-REST-POSE — the pose DS1 draws a static, skinned prop in.
/// A GO with a chore dictionary is drawn through its animation, not at the
/// mesh bind: before anything plays, the prop sits at the first frame of the
/// clip that would move it out of its rest state (chore_default, else
/// chore_open — the pose just before it opens). When that clip keys the root
/// bone away from the bind, the whole mesh moves with it, and the shipped
/// placements are authored against the posed mesh. See
/// <see cref="AnimationRuntime.TryRootRestOffset"/>.</summary>
public static class PropRestPose
{
static readonly string[] RestChores = { "chore_default", "chore_open", "chore_close" };

/// <summary>PRS basenames (with .prs) to try for the rest clip, best first.
/// Item/prop chores author no stances, so the file is
/// <c>{chore_prefix}_{anim}.prs</c> (<c>a_i_glb_switch-06-close.prs</c>);
/// the stance-composed <c>{chore_prefix}0_{anim}.prs</c> form follows as
/// a fallback.</summary>
public static IReadOnlyList<string> RestClipCandidates(TemplateStore store, Template template)
{
var list = new List<string>();
var prefix = store.GetAttribute(template, "body", "chore_dictionary", "chore_prefix")?.Trim().Trim('"');
if (string.IsNullOrEmpty(prefix)) return list;
var dict = store.GetSection(template, "body", "chore_dictionary");
if (dict is null) return list;
foreach (var choreName in RestChores)
{
var chore = TemplateStore.FindChild(dict, choreName);
if (chore is null) continue;
var anim = FirstAnimFile(chore);
if (string.IsNullOrEmpty(anim)) continue;
list.Add($"{prefix}_{anim}.prs");
list.Add($"{prefix}0_{anim}.prs");
return list;
}
return list;
}

/// <summary>First <c>[anim_files]</c> entry of a chore, in either the
/// nested block form or the <c>anim_files:00 = x;</c> shorthand the item
/// templates use.</summary>
static string? FirstAnimFile(GasNode chore)
{
var files = TemplateStore.FindChild(chore, "anim_files");
if (files is not null && files.Attributes.Count > 0)
return files.Attributes[0].Value?.Trim().Trim('"');
foreach (var attr in chore.Attributes)
if (attr.Name.StartsWith("anim_files:", StringComparison.OrdinalIgnoreCase))
return attr.Value?.Trim().Trim('"');
return null;
}

/// <summary>The whole-mesh offset (mesh space, applied before the
/// placement) of <paramref name="template"/>'s rest clip on
/// <paramref name="mesh"/>. False when the template authors no clip, none
/// loads, or the clip leaves the root at its bind.</summary>
public static bool TryRestOffset(TemplateStore store, Template template, AspMesh mesh,
Func<string, PrsAnimation?> loadClip, out Matrix4x4 offset)
{
offset = Matrix4x4.Identity;
if (mesh.BoneCount == 0) return false;
foreach (var name in RestClipCandidates(store, template))
{
var clip = loadClip(name);
if (clip is null) continue;
return AnimationRuntime.TryRootRestOffset(mesh, clip, out offset);
}
return false;
}
}
Loading
Loading