From beeb3c41edcd8f57e70dfe5e9c0d2816f0d19e5e Mon Sep 17 00:00:00 2001 From: qwerin Date: Wed, 4 Nov 2026 18:38:37 +0100 Subject: [PATCH 1/3] Draw skinned props in their rest pose: teleporter button sat a unit in the air The MP town transport stone's floating button is lever_glb_06 (m_i_glb_switch-06) placed on the teleport-hub elevator node 1.13 above the platform. Its press clips (a_i_glb_switch-06-open/close) hold Bone01 one unit down mesh -Z from the bind for the whole clip; DS1 draws the GO posed, and every placement is authored for the posed mesh. SiegeFX drew the static prop at bind, so the button hung ~1 unit off its surface. The whole lever_glb_01/02/03/06 switch family carries the same offset: the dm_r8a and cr_r1 wall switches likewise sit exactly 1.0 in front of their wall faces at bind and land flush once posed. Static skinned props (not rigid-root) now apply the root bone's skin matrix from their rest clip (chore_default, else chore_open, at t=0) when the clip holds the root still and off the bind. A root that moves through the clip (statue_cav_rise_01) is left alone. The offset is applied inside the scale_multiplier, unscaled: the other MP towns' buttons keep the template's scale 2 and sit on their platform only then. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 99557177da6f117dd9077c49abe5d20cde33fd89) --- src/SiegeFX.Core/Assets/AnimationRuntime.cs | 44 ++++++ src/SiegeFX.Core/Assets/PropRestPose.cs | 73 ++++++++++ src/SiegeFX.Runtime/Render/RenderHost.cs | 57 ++++++++ tests/SiegeFX.Tests/PropRestPoseTests.cs | 154 ++++++++++++++++++++ 4 files changed, 328 insertions(+) create mode 100644 src/SiegeFX.Core/Assets/PropRestPose.cs create mode 100644 tests/SiegeFX.Tests/PropRestPoseTests.cs diff --git a/src/SiegeFX.Core/Assets/AnimationRuntime.cs b/src/SiegeFX.Core/Assets/AnimationRuntime.cs index 8c04e822..15e41e84 100644 --- a/src/SiegeFX.Core/Assets/AnimationRuntime.cs +++ b/src/SiegeFX.Core/Assets/AnimationRuntime.cs @@ -122,6 +122,50 @@ public static void ComputeSkinMatrices(AspMesh mesh, PrsAnimation anim, float ti skinOut[i] = mesh.InverseBindMatrices[i] * worldAnim[i]; } + /// 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 + /// . 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. + 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; + } + /// Compose the per-bone animated world matrices (mesh-local) for the given /// clip at . Returns one matrix per ASP bone: the bone's /// animated parent-space local transform walked up the hierarchy. Used by weapon/ diff --git a/src/SiegeFX.Core/Assets/PropRestPose.cs b/src/SiegeFX.Core/Assets/PropRestPose.cs new file mode 100644 index 00000000..20c5d0bd --- /dev/null +++ b/src/SiegeFX.Core/Assets/PropRestPose.cs @@ -0,0 +1,73 @@ +using System.Numerics; + +namespace SiegeFX.Core.Assets; + +/// 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 +/// . +public static class PropRestPose +{ + static readonly string[] RestChores = { "chore_default", "chore_open", "chore_close" }; + + /// PRS basenames (with .prs) to try for the rest clip, best first. + /// Item/prop chores author no stances, so the file is + /// {chore_prefix}_{anim}.prs (a_i_glb_switch-06-close.prs); + /// the stance-composed {chore_prefix}0_{anim}.prs form follows as + /// a fallback. + public static IReadOnlyList RestClipCandidates(TemplateStore store, Template template) + { + var list = new List(); + 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; + } + + /// First [anim_files] entry of a chore, in either the + /// nested block form or the anim_files:00 = x; shorthand the item + /// templates use. + 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; + } + + /// The whole-mesh offset (mesh space, applied before the + /// placement) of 's rest clip on + /// . False when the template authors no clip, none + /// loads, or the clip leaves the root at its bind. + public static bool TryRestOffset(TemplateStore store, Template template, AspMesh mesh, + Func 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; + } +} diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs index 88d1fcc7..80d0b002 100644 --- a/src/SiegeFX.Runtime/Render/RenderHost.cs +++ b/src/SiegeFX.Runtime/Render/RenderHost.cs @@ -15907,6 +15907,18 @@ private void LoadStaticProps(IEnumerable regionPaths) var tex = ResolveAspTexture(asp); var world = ComposePlacementWorld(asp, p.Placement); + // SC-PROP-REST-POSE — a skinned prop whose rest clip + // holds the root bone off the bind (the lever_glb_01-06 + // switch family: one unit down mesh -Z) is drawn by DS1 + // in that pose, and its placements are authored for it. + // At bind, the MP teleporter button floated a unit above + // the platform and wall switches stood a unit off the + // wall. Applied before the scale: the MP towns' buttons + // (template scale 2) sit on the platform only when the + // root offset stays unscaled. + if (RestPoseOffset(ts, template, asp) is { } restOffset) + world = restOffset * world; + // Phase 17-SC-J — DS1 lets the placement override `aspect.scale_multiplier` // (fh_r1's breakable farmhouse door bumps it to 1.5 so the destroyable // variant reads visibly larger than the everyday wooden door using the @@ -23811,6 +23823,51 @@ private static bool ReadBool(SiegeFX.Core.Assets.GasNode node, string name) return false; } + // SC-PROP-REST-POSE — per (template, mesh) rest offset, and the rest + // clips by basename (null = not found / no offset). + private readonly Dictionary<(string Template, AspMesh Mesh), Matrix4x4?> _restPoseOffsetCache = new(); + private readonly Dictionary _restClipCache = new(StringComparer.OrdinalIgnoreCase); + + /// SC-PROP-REST-POSE — the mesh-space offset DS1's rest pose puts + /// on a static skinned prop (see ), + /// or null. Rigid-root props keep their bone-0 correction from + /// and are left alone. + private Matrix4x4? RestPoseOffset(SiegeFX.Core.Assets.TemplateStore store, + SiegeFX.Core.Assets.Template template, AspMesh asp) + { + if (asp.BindPose.Length < 2 || IsRigidRootProp(asp) || _playResolver is null) return null; + var key = (template.Name, asp); + if (_restPoseOffsetCache.TryGetValue(key, out var cached)) return cached; + Matrix4x4? result = null; + try + { + if (SiegeFX.Core.Assets.PropRestPose.TryRestOffset(store, template, asp, LoadRestClip, out var off)) + { + result = off; + Console.WriteLine($" [rest-pose] {template.Name} drawn in its rest pose (root offset {off.Translation})"); + } + } + catch (Exception ex) + { + Console.WriteLine($" [rest-pose] {template.Name}: {ex.Message}"); + } + _restPoseOffsetCache[key] = result; + return result; + } + + private SiegeFX.Core.Assets.PrsAnimation? LoadRestClip(string basename) + { + if (_restClipCache.TryGetValue(basename, out var hit)) return hit; + SiegeFX.Core.Assets.PrsAnimation? clip = null; + if (_playResolver is not null && _playResolver.TryLoadByBasename(basename, out var bytes)) + { + try { clip = SiegeFX.Core.Assets.PrsAnimation.Load(bytes); } + catch { clip = null; } + } + _restClipCache[basename] = clip; + return clip; + } + private Matrix4x4 ComposePlacementWorld(AspMesh asp, SiegeFX.Core.Assets.NodePlacement p) { var bindRoot = ComputeRootBindPose(asp); diff --git a/tests/SiegeFX.Tests/PropRestPoseTests.cs b/tests/SiegeFX.Tests/PropRestPoseTests.cs new file mode 100644 index 00000000..1d024896 --- /dev/null +++ b/tests/SiegeFX.Tests/PropRestPoseTests.cs @@ -0,0 +1,154 @@ +using System.Numerics; +using System.Text; +using SiegeFX.Core.Assets; + +namespace SiegeFX.Tests; + +/// SC-PROP-REST-POSE — the lever_glb_06 teleporter button: its +/// press clips hold Bone01 one unit down mesh -Z from the bind, and DS1 draws +/// (and the maps place) the posed mesh. +public class PropRestPoseTests +{ + // m_i_glb_switch-06's skeleton: Bone01 (root, -90°X, y -0.183) → Bone02. + static readonly Quaternion RootRot = new(-0.707107f, 0f, 0f, 0.707107f); + static readonly Vector3 RootBind = new(0f, -0.183f, 0f); + static readonly Vector3 ChildBind = new(0f, -0.0016f, 0.1856f); + + static AspMesh SwitchMesh() + { + var bind = new[] + { + new AspMesh.Transform(RootRot, RootBind), + new AspMesh.Transform(Quaternion.Identity, ChildBind), + }; + var root = Matrix4x4.CreateFromQuaternion(RootRot) * Matrix4x4.CreateTranslation(RootBind); + var child = Matrix4x4.CreateTranslation(ChildBind) * root; + Matrix4x4.Invert(root, out var invRoot); + Matrix4x4.Invert(child, out var invChild); + return new AspMesh + { + BoneNames = new[] { "Bone01", "Bone02" }, + BoneParents = new[] { -1, 0 }, + BindPose = bind, + InverseBindMatrices = new[] { invRoot, invChild }, + }; + } + + /// A v3 PRS: per bone, rotation + position keys at t = 0..len + /// (one entry per key). + static byte[] Prs(float length, params (string Bone, Quaternion Rot, Vector3[] Pos)[] bones) + { + var ms = new MemoryStream(); + var w = new BinaryWriter(ms); + var names = new MemoryStream(); + foreach (var b in bones) + { + var s = Encoding.ASCII.GetBytes(b.Bone); + names.Write(s); + names.WriteByte(0); + while (names.Length % 4 != 0) names.WriteByte(0); + } + w.Write(0x4D494E41u); w.Write(3u); + w.Write((uint)names.Length); w.Write((uint)bones.Length); w.Write(length); + for (int i = 0; i < 3; i++) w.Write(0f); // root travel + for (int i = 0; i < 9; i++) w.Write(0f); // two quats + float + w.Write(names.ToArray()); + for (int i = 0; i < bones.Length; i++) + { + var b = bones[i]; + w.Write(0x54534C4Bu); w.Write(3u); w.Write((uint)i); w.Write(0u); + w.Write(1u); w.Write((uint)b.Pos.Length); + w.Write(0f); w.Write(b.Rot.X); w.Write(b.Rot.Y); w.Write(b.Rot.Z); w.Write(b.Rot.W); + for (int k = 0; k < b.Pos.Length; k++) + { + w.Write(b.Pos.Length == 1 ? 0f : length * k / (b.Pos.Length - 1)); + w.Write(b.Pos[k].X); w.Write(b.Pos[k].Y); w.Write(b.Pos[k].Z); + } + } + w.Write(0x444E4541u); + return ms.ToArray(); + } + + static TemplateStore LeverStore() + { + var store = new TemplateStore(); + store.RegisterFromGasText(""" + [t:template,n:lever_glb_06] + { + [aspect] { model = m_i_glb_switch-06; } + [body] + { + [chore_dictionary] + { + chore_prefix = a_i_glb; + [chore_open] { skrit = transition; anim_files:00 = switch-06-close; } + [chore_close] { skrit = transition; anim_files:00 = switch-06-open; } + } + } + } + """); + return store; + } + + [Fact] + public void TheButtonsHeldRootDropsItOntoThePlatform() + { + // a_i_glb_switch-06-close: Bone01 keyed at (0,-0.183,-1) the whole + // clip, Bone02 pressing in and out. + var clip = PrsAnimation.Load(Prs(1.5f, + ("Bone01", RootRot, new[] { new Vector3(0f, -0.183f, -1f) }), + ("Bone02", Quaternion.Identity, new[] { ChildBind, ChildBind + new Vector3(0, 0, -0.05f), ChildBind }))); + var store = LeverStore(); + Assert.True(store.TryGet("lever_glb_06", out var tpl)); + var requested = new List(); + Assert.True(PropRestPose.TryRestOffset(store, tpl, SwitchMesh(), + n => { requested.Add(n); return n == "a_i_glb_switch-06-close.prs" ? clip : null; }, out var off)); + Assert.Equal("a_i_glb_switch-06-close.prs", requested[0]); + + // One unit down mesh -Z, no rotation. + Assert.Equal(0f, off.M41, 3); + Assert.Equal(0f, off.M42, 3); + Assert.Equal(-1f, off.M43, 3); + Assert.Equal(1f, off.M11, 3); + Assert.Equal(1f, off.M22, 3); + + // The town_center placement: -90°X at y 1.135 over the platform node. + // Posed, the button's centre lands ~0.13 above the node origin — on + // the 0.1-high platform — instead of hanging at 1.13. + var place = Matrix4x4.CreateFromQuaternion(new Quaternion(-0.707107f, 0f, 0f, 0.707107f)) + * Matrix4x4.CreateTranslation(0.007f, 1.13457f, 0.0077f); + var centre = Vector3.Transform(Vector3.Zero, off * place); + Assert.InRange(centre.Y, 0.1f, 0.2f); + } + + [Fact] + public void ARootThatMovesDuringTheClipIsNotARestOffset() + { + // statue_cav_rise_01: the root rises through the clip — motion, not + // an authoring offset. + var clip = PrsAnimation.Load(Prs(2f, + ("Bone01", RootRot, new[] { new Vector3(0f, -0.183f, -3f), new Vector3(0f, -0.183f, -1.5f), RootBind }))); + var store = LeverStore(); + store.TryGet("lever_glb_06", out var tpl); + Assert.False(PropRestPose.TryRestOffset(store, tpl, SwitchMesh(), _ => clip, out var off)); + Assert.Equal(Matrix4x4.Identity, off); + } + + [Fact] + public void ARootHeldAtTheBindIsNoOffset() + { + var clip = PrsAnimation.Load(Prs(1f, ("Bone01", RootRot, new[] { RootBind }))); + var store = LeverStore(); + store.TryGet("lever_glb_06", out var tpl); + Assert.False(PropRestPose.TryRestOffset(store, tpl, SwitchMesh(), _ => clip, out _)); + } + + [Fact] + public void NoChoreAnimsMeansNoCandidates() + { + var store = new TemplateStore(); + store.RegisterFromGasText("[t:template,n:crate] { [aspect] { model = m_crate; } }"); + store.TryGet("crate", out var tpl); + Assert.Empty(PropRestPose.RestClipCandidates(store, tpl)); + } +} From 9eed6060bc23b11e5e04ffdf0d55c6034b977933 Mon Sep 17 00:00:00 2001 From: qwerin Date: Wed, 4 Nov 2026 20:33:23 +0100 Subject: [PATCH 2/3] Doors: walk into use range before opening (no more opening from afar) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A door click opened the nearest door to the click point at once, from any distance (OpenDoorNear ran straight from the click handler). It now queues a walk-up-and-open like chests and levers: the acting character walks to the click and the door opens only when they are within its reach — the leaf's footprint radius plus [aspect] use_range, measured from the hinge, same floor (UseRange.DoorInRange, unit-tested). Co-Authored-By: Claude Opus 5.5 --- src/SiegeFX.Core/Geometry/UseRange.cs | 56 ++++++++++++++++++++++++ src/SiegeFX.Runtime/Render/RenderHost.cs | 52 ++++++++++++++++++---- tests/SiegeFX.Tests/UseRangeTests.cs | 53 ++++++++++++++++++++++ 3 files changed, 153 insertions(+), 8 deletions(-) create mode 100644 src/SiegeFX.Core/Geometry/UseRange.cs create mode 100644 tests/SiegeFX.Tests/UseRangeTests.cs diff --git a/src/SiegeFX.Core/Geometry/UseRange.cs b/src/SiegeFX.Core/Geometry/UseRange.cs new file mode 100644 index 00000000..a5233de3 --- /dev/null +++ b/src/SiegeFX.Core/Geometry/UseRange.cs @@ -0,0 +1,56 @@ +using System.Numerics; + +namespace SiegeFX.Core.Geometry; + +/// Walk-up-and-use reach for placed world objects. DS1 measures an +/// object's [aspect] use_range from the edge of its footprint, not +/// from its origin: a door's origin is its hinge, and its closed leaf is a +/// path blocker about one leaf-length wide, so the nearest spot a hero can +/// stand is already that far from the hinge. +public static class UseRange +{ + /// Default use_range when a door template authors none. + public const float DefaultDoorUseRange = 1.5f; + + /// Height band (world units) inside which a use counts as on the + /// same floor as the object. + public const float DoorMaxDy = 3f; + + /// XZ footprint radius of a placed prop: the farthest corner of + /// its local AABB (Y collapsed to the middle) from the placement origin + /// in world XZ. Same measure the nav-obstacle stamping uses, so "in use + /// range" agrees with where the blocker lets the hero stand. + public static float FootprintRadius(Matrix4x4 world, Vector3 localMin, Vector3 localMax) + { + float midY = (localMin.Y + localMax.Y) * 0.5f; + var origin = world.Translation; + float maxR2 = 0f; + Span corners = stackalloc Vector3[4] + { + new(localMin.X, midY, localMin.Z), + new(localMax.X, midY, localMin.Z), + new(localMax.X, midY, localMax.Z), + new(localMin.X, midY, localMax.Z), + }; + foreach (var c in corners) + { + var w = Vector3.Transform(c, world); + float dx = w.X - origin.X, dz = w.Z - origin.Z; + maxR2 = MathF.Max(maxR2, dx * dx + dz * dz); + } + return MathF.Sqrt(maxR2); + } + + /// True when is close enough to use a + /// door placed at : XZ distance from the hinge no + /// more than the leaf footprint plus , and on + /// the same floor. + public static bool DoorInRange(Vector3 actor, Matrix4x4 world, Vector3 localMin, Vector3 localMax, float useRange) + { + var o = world.Translation; + if (MathF.Abs(actor.Y - o.Y) > DoorMaxDy) return false; + float reach = FootprintRadius(world, localMin, localMax) + MathF.Max(0f, useRange); + float dx = actor.X - o.X, dz = actor.Z - o.Z; + return dx * dx + dz * dz <= reach * reach; + } +} diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs index 80d0b002..dc3ca270 100644 --- a/src/SiegeFX.Runtime/Render/RenderHost.cs +++ b/src/SiegeFX.Runtime/Render/RenderHost.cs @@ -423,6 +423,9 @@ private sealed class FlameSource { public Vector3 Pos; public float Carry; publi private readonly List _chestProps = new(); private readonly Dictionary _chestsByScid = new(); private StaticPropInstance? _pendingChestUse; + // SC-DOOR-USE-RANGE — a clicked door waits here until the acting + // character is inside its use range (walk-up-and-open, like chests). + private StaticPropInstance? _pendingDoorUse; private sealed class AutoTrapState { public uint Scid; @@ -21962,6 +21965,21 @@ private void TickElevators(float dt) } } + // SC-DOOR-USE-RANGE — pending door walk-up (same shape as chests). + if (_pendingDoorUse is { } door) + { + if (door.IsDestroyed || door.DoorTargetOpen) _pendingDoorUse = null; + else if (ActingCharacter() is { IsDead: false } doorActor && + DoorInUseRange(door, doorActor.CurrentTransform.Translation)) + { + _pendingDoorUse = null; + var ap = doorActor.CurrentTransform.Translation; + var dp = door.World.Translation; + Console.WriteLine($"[door] 0x{door.Scid:X8} reached: actor {MathF.Sqrt((ap.X - dp.X) * (ap.X - dp.X) + (ap.Z - dp.Z) * (ap.Z - dp.Z)):F1}u from the hinge"); + UseDoor(door); + } + } + // ALPHA-2C — pending chest walk-up (same shape as levers). if (_pendingChestUse is { } chest) { @@ -22612,14 +22630,14 @@ private void LoadAutoTraps(IEnumerable regionPaths) Console.WriteLine($" auto-traps: {added} armed ({_autoTraps.Count} total)"); } - // SC-DOORS-OPEN — a left-click that lands on/near a door opens it (DS1 + // SC-DOORS-OPEN — a left-click that lands on/near a door picks it (DS1 // doors are click-to-use, not proximity). The click ray is resolved // against the navmesh, so a tall door's base sits at ~hit; the door's // World.Translation is its hinge (the asp origin), and the leaf reaches // ~1.6u from there, so a ~2u radius covers a click anywhere on the leaf. - private void OpenDoorNear(Vector3 worldPoint) + private StaticPropInstance? PickDoorNear(Vector3 worldPoint) { - if (_doorProps.Count == 0) return; + if (_doorProps.Count == 0) return null; StaticPropInstance? best = null; float bestD2 = 2.0f * 2.0f; foreach (var d in _doorProps) @@ -22630,8 +22648,23 @@ private void OpenDoorNear(Vector3 worldPoint) float dd = dx * dx + dz * dz; if (dd < bestD2) { bestD2 = dd; best = d; } } - if (best is null) return; + return best; + } + + /// SC-DOOR-USE-RANGE — a click on a door queues a walk-up-and- + /// open: the acting character walks to the click and the door opens + /// only once they are inside its [aspect] use_range (TickElevators). A + /// click that misses every door clears any pending open. + private void RequestDoorUseNear(Vector3 worldPoint) => _pendingDoorUse = PickDoorNear(worldPoint); + private bool DoorInUseRange(StaticPropInstance door, Vector3 actorPos) => + SiegeFX.Core.Geometry.UseRange.DoorInRange(actorPos, door.World, door.Mesh.Min, door.Mesh.Max, door.DoorUseRange); + + /// Use a door right now (range already satisfied, or a use the + /// host has accepted). + private void UseDoor(StaticPropInstance best) + { + if (best.IsDestroyed || best.DoorTargetOpen) return; // ALPHA-2E — use_toggle doors are scripted gates: clicking shows the // authored stuck/locked line; only a quest message opens them. if (best.DoorUseToggle) @@ -29996,10 +30029,10 @@ private void TryClickToMove(Vector2 cursorPx) // SC-NAV-PARTIAL-PATH — the whole-mesh fallback is for PLAYER orders // only (opt-in; ambient wanderers keep cheap fail-and-reroll). _playerFollower.PartialPathFallback = true; - // SC-DOORS-OPEN — clicking on/near a door opens it regardless of who - // is selected (retail: the first-selected character works doors; the - // ordered members walk to the click and pass through). - OpenDoorNear(hit); + // SC-DOORS-OPEN / SC-DOOR-USE-RANGE — clicking on/near a door queues + // a walk-up-and-open for the acting (first-selected) character + // below; it opens once they reach use range, never from afar. + _pendingDoorUse = null; // SC-FIRST-SELECTED (blindspot E3) — a member-only selection can // work levers/chests/usables too: the request latches queue the // same way, and their arrival checks test the ACTING character @@ -30013,6 +30046,8 @@ private void TryClickToMove(Vector2 cursorPx) RequestLeverUseNear(near, dir, hit); // ALPHA-2C — same walk-up pattern for chests. RequestChestUseNear(hit); + // SC-DOOR-USE-RANGE — and for doors. + RequestDoorUseNear(hit); // ALPHA-2E — and for locked usables (Star Device). RequestLockedUseNear(hit); if (heroSelected) @@ -42694,6 +42729,7 @@ private void OnClosing() _chestProps.Clear(); _chestsByScid.Clear(); _pendingChestUse = null; + _pendingDoorUse = null; _autoTraps.Clear(); // ALPHA-2D — shrines re-register with their regions. _shrinesByScid.Clear(); diff --git a/tests/SiegeFX.Tests/UseRangeTests.cs b/tests/SiegeFX.Tests/UseRangeTests.cs new file mode 100644 index 00000000..6e2c7a9b --- /dev/null +++ b/tests/SiegeFX.Tests/UseRangeTests.cs @@ -0,0 +1,53 @@ +using System.Numerics; +using SiegeFX.Core.Geometry; + +namespace SiegeFX.Tests; + +public class UseRangeTests +{ + // A DS1 wall door: mesh authored Z-up, hinge at the local origin, the + // leaf running 1.6u along -X, 0.2u thick (Y), 3u tall (Z). The placement + // bakes the Z-up→Y-up standup, a yaw, and the world position. + static readonly Vector3 Min = new(-1.6f, -0.1f, 0f); + static readonly Vector3 Max = new(0f, 0.1f, 3f); + static readonly Vector3 Hinge = new(100f, 5f, -40f); + static readonly Matrix4x4 World = + Matrix4x4.CreateRotationX(-MathF.PI / 2f) + * Matrix4x4.CreateRotationY(0.7f) + * Matrix4x4.CreateTranslation(Hinge); + + [Fact] + public void FootprintIsTheLeafLength() => + Assert.Equal(1.6f, UseRange.FootprintRadius(World, Min, Max), 3); + + [Fact] + public void StandingInFrontOfTheLeafIsInRange() + { + // The leaf's middle, stepped 1.2u off its face (the closed door + // blocks the doorway, so this is about as close as a hero gets). + var mid = Vector3.Transform(new Vector3(-0.8f, 0f, 0f), World); + var face = Vector3.Normalize(Vector3.TransformNormal(Vector3.UnitY, World)); + Assert.True(UseRange.DoorInRange(mid + face * 1.2f, World, Min, Max, UseRange.DefaultDoorUseRange)); + } + + [Fact] + public void FarAwayIsOutOfRange() + { + Assert.False(UseRange.DoorInRange(Hinge + new Vector3(8f, 0f, 0f), World, Min, Max, UseRange.DefaultDoorUseRange)); + // Either side of footprint (1.6) + use_range (1.5). + Assert.False(UseRange.DoorInRange(Hinge + new Vector3(0f, 0f, 3.2f), World, Min, Max, UseRange.DefaultDoorUseRange)); + Assert.True(UseRange.DoorInRange(Hinge + new Vector3(0f, 0f, 3.0f), World, Min, Max, UseRange.DefaultDoorUseRange)); + } + + [Fact] + public void AnotherFloorIsOutOfRange() => + Assert.False(UseRange.DoorInRange(Hinge + new Vector3(0.5f, -4f, 0f), World, Min, Max, UseRange.DefaultDoorUseRange)); + + [Fact] + public void AuthoredUseRangeWidensTheReach() + { + var p = Hinge + new Vector3(4f, 0f, 0f); + Assert.False(UseRange.DoorInRange(p, World, Min, Max, 1.5f)); + Assert.True(UseRange.DoorInRange(p, World, Min, Max, 3f)); + } +} From a6151f9f764959177fe6eca9f14b9adecd8dc2f7 Mon Sep 17 00:00:00 2001 From: qwerin Date: Wed, 4 Nov 2026 21:09:42 +0100 Subject: [PATCH 3/3] Town teleporters work: elevator_instant_4s_1c hub rides, button needs a press Every multiplayer_world town's transport stone is an elevator_instant_4s_1c gizmo. The skrit (components/elevators/elevator_instant_4s_1c.skrit) drives TWO cars between two long shaft nodes: the town car (elevator_node) and its twin in the new_hub region (e2_elevator_node). A press raises both cars to level 2 of their own shaft (duration_locationN_moving_to_lv2, default 2s), pops each to level 2 of the other shaft (SRequestNodeConnection), and sinks them to level 1 there (duration_..._to_lv1, 16s authored) - the riders step off in the hub. The 8 towns (town_center, des_r1, snow_town, oasis, mine_town, swamp_town, castle_town, ocean) each pair with one of new_hub's 8 gizmos (other_elevator); 9 more pairs link regions point to point. Actioninfo is a mood name here (multiplayer_world_teleport darkens the shaft, multiplayer_world__teleport sets the arrival). ElevatorStore only read connect_node / connect_node_levelN, so these gizmos were skipped and the button did nothing. Now: - ElevatorStore parses the connect_node_location1/2 form (8 docks, both cars, durations, actioninfo, effects) into ElevatorDef.Instant; InstantElevatorPlan is the skrit's state machine as data. - new_hub has no stitch_helper, so no world position: it streams in when a teleporter is pressed and WorldLayout places it as an island at a pinned, deterministic offset (same on every co-op machine). The press is queued and handled from TickElevators (streaming adds triggers / rebuilds nav). - The runtime rides both cars, carries riders through the pop, applies the moods to this machine's hero, re-bakes nav, and moves objects placed on a car node with it (the button and its use points ride the car). - The town and hub gizmos of one teleporter share one runtime (scid alias). - Trigger rows mixing receive_world_message with a volume condition in boundary mode wait_for_every/first_message now treat the volume as a gate on the message (the editor annotates it "message checker"). Before, the conditions were independent sources: stepping onto the platform launched the teleporter, and arriving on it launched it straight back. Verified in --play-region town_center: town -> hub -> town round trip, rider carried both ways, button usable at the far end. Co-Authored-By: Claude Opus 5.5 --- src/SiegeFX.Core/Actors/TriggerRuntime.cs | 44 ++ src/SiegeFX.Core/Assets/ElevatorStore.cs | 158 +++++- src/SiegeFX.Core/Assets/WorldLayout.cs | 28 +- .../Render/RenderHost.InstantElevator.cs | 478 ++++++++++++++++++ src/SiegeFX.Runtime/Render/RenderHost.cs | 179 ++++--- tests/SiegeFX.Tests/InstantElevatorTests.cs | 241 +++++++++ 6 files changed, 1041 insertions(+), 87 deletions(-) create mode 100644 src/SiegeFX.Runtime/Render/RenderHost.InstantElevator.cs create mode 100644 tests/SiegeFX.Tests/InstantElevatorTests.cs diff --git a/src/SiegeFX.Core/Actors/TriggerRuntime.cs b/src/SiegeFX.Core/Actors/TriggerRuntime.cs index 5366ee3b..c9efa90a 100644 --- a/src/SiegeFX.Core/Actors/TriggerRuntime.cs +++ b/src/SiegeFX.Core/Actors/TriggerRuntime.cs @@ -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(); 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; @@ -388,6 +395,43 @@ void EvaluateInstance(TriggerInstance trig, TriggerContext ctx) } } + static bool IsMessageCondition(TriggerCall cond) => + cond.Verb.Equals("receive_world_message", StringComparison.OrdinalIgnoreCase); + + /// Boundary mode wait_for_every_message / + /// wait_for_first_message (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. + 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; diff --git a/src/SiegeFX.Core/Assets/ElevatorStore.cs b/src/SiegeFX.Core/Assets/ElevatorStore.cs index 21f613fb..e6187ce4 100644 --- a/src/SiegeFX.Core/Assets/ElevatorStore.cs +++ b/src/SiegeFX.Core/Assets/ElevatorStore.cs @@ -32,7 +32,100 @@ public sealed record ElevatorDef( string Moving1ActionInfo, string Moving2ActionInfo, string Moving1Message, string Moving2Message, uint Moving1Scid, uint Moving2Scid, - uint Level2Scid); + uint Level2Scid) +{ + /// SC-ELEVATOR-INSTANT — set for elevator_instant_4s_1c + /// (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. + public InstantElevatorSpec? Instant { get; init; } +} + +/// One docking pose of an instant-elevator car: the car's +/// mates with on +/// (same door alignment as every elevator stop). +public readonly record struct InstantDock(uint ConnectGuid, int ConnectDoorId, int CarDoorId); + +/// SC-ELEVATOR-INSTANT — the hub teleporter (component +/// elevator_instant_4s_1c.skrit). TWO cars (elevator_node and +/// e2_elevator_node) and two locations (connect_node_location1/2, +/// 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 (other_elevator). +/// Lists index by location − 1. +public sealed record InstantElevatorSpec( + uint Car2NodeGuid, + IReadOnlyList Docks, + IReadOnlyList AscendSeconds, + IReadOnlyList DescendSeconds, + IReadOnlyList ToLevel2ActionInfo, + IReadOnlyList ToLevel1ActionInfo, + string UpEffect, + string DownEffect, + uint OtherElevatorScid, + uint GizmoNodeGuid, + Vector3 GizmoLocalPosition) +{ + /// Car 0 = elevator_node, car 1 = e2_elevator_node. + 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; +} + +/// One car's moves in one instant-elevator ride: rise lv1 → lv2 at +/// , pop to lv2 of , +/// sink to lv1 there. +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; + + /// The skrit's action delay: min(duration / 2, 0.5) into + /// each move, the party members still on the car get the actioninfo. + public static float ActionDelay(float duration) => MathF.Min(duration / 2f, 0.5f); +} + +/// SC-ELEVATOR-INSTANT — the state machine of +/// elevator_instant_4s_1c.skrit as data. Stop s = car 1 at location s, +/// car 2 at the other one. +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; + + /// The ride leaving . Location1_start + /// rises for duration_location1_moving_to_lv2; its swap lands in + /// Location2_descending (duration_location2_moving_to_lv1). + /// 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). + 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)); + } +} /// SC-STAIRWELL — the crypt rotating-stairwell mechanism /// (elevator_hidden_stairwell / _act_deact): N stair-segment @@ -140,6 +233,12 @@ public static (IReadOnlyList Defs, IReadOnlyList 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")); @@ -174,6 +273,63 @@ public static (IReadOnlyList Defs, IReadOnlyList Diagnostic return (defs, extraDiags); } + /// SC-ELEVATOR-INSTANT — elevator_instant_4s_1c. 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. + static ElevatorDef? ParseInstant(ActorInstance p, GasNode comp, string regionPath, uint car1, List 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) diff --git a/src/SiegeFX.Core/Assets/WorldLayout.cs b/src/SiegeFX.Core/Assets/WorldLayout.cs index fa499c36..7b2d8541 100644 --- a/src/SiegeFX.Core/Assets/WorldLayout.cs +++ b/src/SiegeFX.Core/Assets/WorldLayout.cs @@ -84,7 +84,8 @@ private WorldLayout( public static WorldLayout Build( IReadOnlyList regions, Func resolveSno, - string? rootHint = null) + string? rootHint = null, + IReadOnlyDictionary? islandOffsets = null) { if (regions.Count == 0) return new WorldLayout("", new Dictionary(), @@ -155,8 +156,31 @@ public static WorldLayout Build( var queue = new Queue(); queue.Enqueue(root); - while (queue.Count > 0) + // SC-ELEVATOR-INSTANT — islands: regions no stitch reaches (the + // multiplayer_world new_hub, whose only way in is the town + // teleporters) sit at the caller's pinned offset. DS1 has no world + // frame — an island is simply wherever its nodes are relative to the + // car docked in it — so any spot clear of the stitched world works; + // islands are seeded only after the root's whole stitched component + // is placed, so they never displace a region the root reaches. + var pendingIslands = new Queue(); + if (islandOffsets is not null) + foreach (var path in islandOffsets.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + if (entryByPath.ContainsKey(path)) pendingIslands.Enqueue(path); + + while (queue.Count > 0 || pendingIslands.Count > 0) { + if (queue.Count == 0) + { + var island = pendingIslands.Dequeue(); + if (regionOffsets.ContainsKey(island)) continue; + var islandOffset = islandOffsets![island]; + regionOffsets[island] = islandOffset; + foreach (var (g, local) in entryByPath[island].Layout.Transforms) + transforms[g] = local * islandOffset; + queue.Enqueue(island); + continue; + } var curRegionPath = queue.Dequeue(); var curEntry = entryByPath[curRegionPath]; var curOffset = regionOffsets[curRegionPath]; diff --git a/src/SiegeFX.Runtime/Render/RenderHost.InstantElevator.cs b/src/SiegeFX.Runtime/Render/RenderHost.InstantElevator.cs new file mode 100644 index 00000000..692dc3f7 --- /dev/null +++ b/src/SiegeFX.Runtime/Render/RenderHost.InstantElevator.cs @@ -0,0 +1,478 @@ +using System.Numerics; +using SiegeFX.Core.Assets; +using SiegeFX.Core.Tank; + +namespace SiegeFX.Runtime.Render; + +// ── SC-ELEVATOR-INSTANT — the multiplayer_world town teleporters ──────────── +// Every town's transport stone is an elevator_instant_4s_1c gizmo (see +// InstantElevatorSpec): the town's car and its twin in the new_hub island +// rise up their shafts, swap ends, and sink — whoever stood on the town car +// steps off in the hub. new_hub has no stitch to anything, so it is streamed +// on demand and placed as an island (WorldLayout islandOffsets). The far end +// of a teleporter is streamed the first time someone presses its button: the +// press is queued and handled from TickElevators, never inside the trigger +// dispatch that delivered it (streaming adds triggers and rebuilds nav). +public sealed partial class RenderHost +{ + // Island regions' pinned world offsets (see PreloadAroundRegion). + private readonly Dictionary _islandRegionOffsets = new(StringComparer.OrdinalIgnoreCase); + // Instant gizmos parsed from a streamed region whose far end is not + // streamed yet, by gizmo scid. + private readonly Dictionary _pendingInstantElevators = new(); + // Queued activations (true = start the ride once placed). + private readonly Dictionary _instantElevatorRequests = new(); + private Dictionary? _nodeRegionIndex; + // Objects placed on an elevator car node, with their pose relative to it. + private readonly Dictionary _carPropLocal = new(); + + /// Islands sit far above the stitched world on a grid keyed by + /// the region's sorted index — the same spot on every machine of a co-op + /// game, clear of every real region and of each other. + private static Matrix4x4 IslandRegionOffset(int regionIndex) + { + const float Spacing = 2500f; + int col = regionIndex % 12, row = regionIndex / 12; + return Matrix4x4.CreateTranslation((col - 6) * Spacing, 6000f, (row - 6) * Spacing); + } + + /// Instant gizmo scid (or the scid of its other end) the client + /// can resolve without the far end streamed: its pending def. + private bool TryGetPendingInstant(uint scid, out ElevatorDef def) + { + if (_pendingInstantElevators.TryGetValue(scid, out def!)) return true; + foreach (var d in _pendingInstantElevators.Values) + if (d.Instant!.OtherElevatorScid == scid) { def = d; return true; } + def = null!; + return false; + } + + private void RequestInstantElevator(uint scid, bool startRide) + { + _instantElevatorRequests[scid] = startRide || (_instantElevatorRequests.TryGetValue(scid, out var s) && s); + Console.WriteLine($"[elevator] 0x{scid:X8} teleporter called — streaming its far end"); + } + + /// From TickElevators: stream the regions a queued teleporter + /// needs, place it, then ride (or apply the host's word). + private void ProcessInstantElevatorRequests() + { + if (_instantElevatorRequests.Count == 0) return; + var requests = _instantElevatorRequests.ToList(); + _instantElevatorRequests.Clear(); + foreach (var (scid, startRide) in requests) + { + if (!_elevatorsByScid.ContainsKey(scid) && TryGetPendingInstant(scid, out var def)) + { + var spec = def.Instant!; + var nodes = new[] { def.CarNodeGuid, spec.Car2NodeGuid, + spec.Dock(0, 1, 1).ConnectGuid, spec.Dock(0, 2, 1).ConnectGuid }; + foreach (var g in nodes) + { + if (TryGetNodeMeshGuid(g, out _)) continue; + var region = RegionOfNode(def.RegionPath, g); + if (region is null) + { + Console.WriteLine($"[elevator] 0x{def.Scid:X8} node 0x{g:X8} is in no region of this map — cannot place"); + continue; + } + if (_loadedRegions.ContainsKey(region)) continue; + OnPlayerRegionChanged(region, updateAnchor: false); + } + if (!_elevatorsByScid.ContainsKey(def.Scid)) + { + LoadElevators(new[] { def.RegionPath }); + if (_elevatorsByScid.ContainsKey(def.Scid)) + { + var nav = RebuildNavMesh(); + if (nav is not null) { _navMesh = nav; MarkAllObstacles(); RehomeAllFollowers(nav); } + } + } + } + if (!_elevatorsByScid.TryGetValue(scid, out var el)) + { + Console.WriteLine($"[elevator] 0x{scid:X8} teleporter could not be placed — press ignored"); + continue; + } + if (startRide) + StartElevatorRide(el); + } + } + + /// Region path holding , from every + /// region's index/streamer_node_index.gas (indexed once). + private string? RegionOfNode(string anyRegionPath, uint nodeGuid) + { + if (_playMapTank is null) return null; + if (_nodeRegionIndex is null) + { + _nodeRegionIndex = new Dictionary(); + int at = anyRegionPath.IndexOf("/regions/", StringComparison.OrdinalIgnoreCase); + if (at < 0) return null; + var prefix = anyRegionPath[..(at + "/regions/".Length)]; + var reader = new TankReader(_playMapTank); + foreach (var f in reader.ListFiles()) + { + if (!f.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) continue; + if (!f.EndsWith("/index/streamer_node_index.gas", StringComparison.OrdinalIgnoreCase)) continue; + var region = f[..^"/index/streamer_node_index.gas".Length]; + try + { + var doc = GasDocument.Load(reader.ExtractToMemory(f)); + void Walk(GasNode n) + { + foreach (var a in n.Attributes) + { + var v = a.Value.Trim(); + if (v.StartsWith("0x", StringComparison.OrdinalIgnoreCase) + && uint.TryParse(v[2..], System.Globalization.NumberStyles.HexNumber, + System.Globalization.CultureInfo.InvariantCulture, out var g)) + _nodeRegionIndex.TryAdd(g, region); + } + foreach (var c in n.Children) Walk(c); + } + foreach (var root in doc.Roots) Walk(root); + } + catch { /* an unreadable index just leaves its nodes unresolved */ } + } + } + return _nodeRegionIndex.TryGetValue(nodeGuid, out var r) ? r : null; + } + + /// Place an instant elevator once both cars and both locations + /// are streamed. The town and hub gizmos of one teleporter drive the same + /// two cars from opposite ends, and their stop numbers coincide (each + /// one's stop 1 = its own car at its own location 1 = the other's car at + /// the other's location 1), so the second one only aliases its scid. + private bool TryPlaceInstantElevator(ElevatorDef def, Func resolve) + { + var spec = def.Instant!; + foreach (var other in _elevators) + { + if (other.Def.Instant is not { } os) continue; + bool sameCars = (other.Def.CarNodeGuid == def.CarNodeGuid && os.Car2NodeGuid == spec.Car2NodeGuid) + || (other.Def.CarNodeGuid == spec.Car2NodeGuid && os.Car2NodeGuid == def.CarNodeGuid); + if (!sameCars) continue; + _elevatorsByScid[def.Scid] = other; + _pendingInstantElevators.Remove(def.Scid); + Console.WriteLine($"[elevator] 0x{def.Scid:X8} is the far end of teleporter 0x{other.Def.Scid:X8}"); + return true; + } + + uint car1 = def.CarNodeGuid, car2 = spec.Car2NodeGuid; + if (!TryGetNodeMeshGuid(car1, out var m1) || !TryGetNodeMeshGuid(car2, out var m2)) return false; + var sno1 = resolve(m1); + var sno2 = resolve(m2); + if (sno1 is null || sno2 is null || _regionLayout is null) return false; + var poses = new Matrix4x4[8]; + for (int car = 0; car < 2; car++) + for (int loc = 1; loc <= 2; loc++) + for (int lv = 1; lv <= 2; lv++) + { + var d = spec.Dock(car, loc, lv); + if (!TryGetNodeMeshGuid(d.ConnectGuid, out var cm) || resolve(cm) is not { } csno || + !_regionLayout.TryGetTransform(d.ConnectGuid, out var cw)) + return false; // that location is not streamed yet + if (!RegionLayout.TryAlignThroughDoor(csno, cw, d.ConnectDoorId, car == 0 ? sno1 : sno2, + d.CarDoorId, out var pose)) + { + Console.WriteLine($"[elevator] 0x{def.Scid:X8} car {car + 1} dock location{loc} level{lv} " + + $"(doors {d.ConnectDoorId}/{d.CarDoorId}) does not align — skipped"); + _pendingInstantElevators.Remove(def.Scid); + return false; + } + poses[InstantElevatorPlan.DockIndex(car, loc, lv)] = pose; + } + + // The stop nodes.gas composes: car 1 authored against location 2 = stop 2. + int atStop = 1; + foreach (var (_, graph, _) in _worldRegionGraphs) + if (graph.TryGetNode(car1, out var n)) + { + bool l1 = false, l2 = false; + foreach (var d in n.Doors) + { + if (d.FarGuid == def.Connect1Guid) l1 = true; + if (d.FarGuid == def.Connect2Guid) l2 = true; + } + if (l2 && !l1) atStop = 2; + break; + } + + var rt = new ElevatorRuntime + { + Def = def, + Stop1 = poses[InstantElevatorPlan.DockIndex(0, 1, 1)], + Stop2 = poses[InstantElevatorPlan.DockIndex(0, 2, 1)], + InstantPoses = poses, + }; + _elevators.Add(rt); + _elevatorsByScid[def.Scid] = rt; + if (spec.OtherElevatorScid != 0) _elevatorsByScid.TryAdd(spec.OtherElevatorScid, rt); + _pendingInstantElevators.Remove(def.Scid); + if (spec.OtherElevatorScid != 0) _pendingInstantElevators.Remove(spec.OtherElevatorScid); + SnapElevatorToStop(rt, atStop); + + // A save taken with the cars swapped parks them swapped. + for (int i = _pendingElevatorSnaps.Count - 1; i >= 0; i--) + { + var es = _pendingElevatorSnaps[i]; + if (es.Scid != def.Scid && es.Scid != spec.OtherElevatorScid) continue; + SnapElevatorToStop(rt, es.AtStop); + _pendingElevatorSnaps.RemoveAt(i); + } + var a = poses[InstantElevatorPlan.DockIndex(0, 1, 1)].Translation; + var b = poses[InstantElevatorPlan.DockIndex(0, 2, 1)].Translation; + Console.WriteLine($"[elevator] 0x{def.Scid:X8} {def.TemplateName} cars 0x{car1:X8}/0x{car2:X8} " + + $"location1=({a.X:F1},{a.Y:F1},{a.Z:F1}) location2=({b.X:F1},{b.Y:F1},{b.Z:F1}) — at stop{rt.AtStop}"); + return true; + } + + private Matrix4x4 InstantPose(ElevatorRuntime el, int car, int location, int level) => + el.InstantPoses![InstantElevatorPlan.DockIndex(car, location, level)]; + + /// Park a lift at (both cars of a + /// teleporter) — save restore, co-op host state, placement. + private void SnapElevatorToStop(ElevatorRuntime el, int stop) + { + el.AtStop = el.TargetStop = stop == 2 ? 2 : 1; + el.Moving = false; + el.T = 0f; + el.Phase = 0; + var pose = el.AtStop == 2 ? el.Stop2 : el.Stop1; + el.PrevCarPos = pose.Translation; + _elevatorNodeOverrides[el.Def.CarNodeGuid] = pose; + UpdateElevatorCarInstance(el, pose); + if (el.Def.Instant is { } spec) + { + var pose2 = InstantPose(el, 1, InstantElevatorPlan.CarLocation(1, el.AtStop), 1); + el.PrevCar2Pos = pose2.Translation; + _elevatorNodeOverrides[spec.Car2NodeGuid] = pose2; + UpdateCarNodeInstance(spec.Car2NodeGuid, ref el.Car2InstanceIdx, pose2); + } + } + + private void StartInstantElevatorRide(ElevatorRuntime el) + { + var spec = el.Def.Instant!; + el.Ride = InstantElevatorPlan.Ride(spec, el.AtStop); + el.TargetStop = el.Ride.ToStop; + el.Moving = true; + el.Phase = 0; + el.T = 0f; + el.ActionFired = false; + el.PrevCarPos = InstantPose(el, 0, el.Ride.Car1.AscendLocation, 1).Translation; + el.PrevCar2Pos = InstantPose(el, 1, el.Ride.Car2.AscendLocation, 1).Translation; + CaptureCarRiders(el.Def.CarNodeGuid, el.CarInstanceIdx, el.Riders); + CaptureCarRiders(spec.Car2NodeGuid, el.Car2InstanceIdx, el.Riders2); + RunInstantEffect(el, spec.UpEffect, 0); + Console.WriteLine($"[elevator] 0x{el.Def.Scid:X8} teleporter stop{el.Ride.FromStop} -> stop{el.Ride.ToStop}: " + + $"rise {el.Ride.AscendSeconds:F1}s, swap, sink {el.Ride.DescendSeconds:F1}s " + + $"({el.Riders.Count}+{el.Riders2.Count} rider(s))"); + } + + /// up_effect / down_effect on both cars (the gizmo and its + /// other_elevator stand on them). + private void RunInstantEffect(ElevatorRuntime el, string effect, int phase) + { + var spec = el.Def.Instant!; + if (effect.Length == 0) return; + var local = spec.GizmoNodeGuid == el.Def.CarNodeGuid || spec.GizmoNodeGuid == spec.Car2NodeGuid + ? spec.GizmoLocalPosition : Vector3.Zero; + for (int car = 0; car < 2; car++) + { + var leg = el.Ride.Leg(car); + var pose = phase == 0 ? InstantPose(el, car, leg.AscendLocation, 1) : InstantPose(el, car, leg.ArriveLocation, 2); + OnTriggerCallSfxScript(effect, null, Vector3.Transform(local, pose)); + } + } + + /// The skrit's two transitions: rise at the old location, pop + /// to the top of the other one (riders jump with their car), sink. + private void TickInstantElevator(ElevatorRuntime el, float dt) + { + var spec = el.Def.Instant!; + float dur = el.Phase == 0 ? el.Ride.AscendSeconds : el.Ride.DescendSeconds; + el.T += dt / MathF.Max(0.25f, dur); + float k = Math.Clamp(el.T, 0f, 1f); + k = k * k * (3f - 2f * k); + for (int car = 0; car < 2; car++) + { + var leg = el.Ride.Leg(car); + var pose = el.Phase == 0 + ? LerpPose(InstantPose(el, car, leg.AscendLocation, 1), InstantPose(el, car, leg.AscendLocation, 2), k) + : LerpPose(InstantPose(el, car, leg.ArriveLocation, 2), InstantPose(el, car, leg.ArriveLocation, 1), k); + MoveInstantCar(el, car, pose); + } + + if (!el.ActionFired && el.T * dur >= InstantRide.ActionDelay(dur)) + { + el.ActionFired = true; + for (int car = 0; car < 2; car++) + { + var leg = el.Ride.Leg(car); + ApplyInstantActionInfo(el.Phase == 0 ? leg.AscendActionInfo : leg.ArriveActionInfo, + car == 0 ? el.Riders : el.Riders2); + } + } + + if (el.T < 1f) return; + if (el.Phase == 0) + { + // OnExitState of LocationN_ascending: SRequestNodeConnection pops + // each car to level 2 of the other location. + el.Phase = 1; + el.T = 0f; + el.ActionFired = false; + for (int car = 0; car < 2; car++) + MoveInstantCar(el, car, InstantPose(el, car, el.Ride.Leg(car).ArriveLocation, 2)); + RunInstantEffect(el, spec.DownEffect, 1); + Console.WriteLine($"[elevator] 0x{el.Def.Scid:X8} teleporter cars swapped ends"); + return; + } + + el.Moving = false; + el.AtStop = el.TargetStop; + el.Phase = 0; + for (int car = 0; car < 2; car++) + { + var final = InstantPose(el, car, InstantElevatorPlan.CarLocation(car, el.AtStop), 1); + MoveInstantCar(el, car, final); + _elevatorNodeOverrides[car == 0 ? el.Def.CarNodeGuid : spec.Car2NodeGuid] = final; + } + var riders = new List(el.Riders.Count + el.Riders2.Count); + riders.AddRange(el.Riders); + riders.AddRange(el.Riders2); + el.Riders.Clear(); + el.Riders2.Clear(); + CompleteElevatorArrival(el, riders); + } + + private void MoveInstantCar(ElevatorRuntime el, int car, Matrix4x4 pose) + { + Vector3 delta; + if (car == 0) + { + UpdateElevatorCarInstance(el, pose); + delta = pose.Translation - el.PrevCarPos; + el.PrevCarPos = pose.Translation; + } + else + { + UpdateCarNodeInstance(el.Def.Instant!.Car2NodeGuid, ref el.Car2InstanceIdx, pose); + delta = pose.Translation - el.PrevCar2Pos; + el.PrevCar2Pos = pose.Translation; + } + if (delta == Vector3.Zero) return; + foreach (var r in car == 0 ? el.Riders : el.Riders2) CarryRider(r, delta); + } + + /// SSendActionToPartyMembersRemainingInNode: the multiplayer + /// teleporters author mood names (multiplayer_world_teleport darkens the + /// shaft, multiplayer_world_new_hub_teleport sets the arrival); a mood is + /// this machine's view, so it applies when this machine's hero rides that + /// car. Comma tuples are fade_nodes like the 2-stop lifts. + private void ApplyInstantActionInfo(string info, List riders) + { + if (info.Length == 0) return; + if (info.Contains(',')) { ApplyElevatorActionInfo(info); return; } + if (_player is null || !riders.Contains(_player)) return; + foreach (var mood in info.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + OnTriggerMoodChange(mood); + } + + /// Arrival: riders walk again on a nav mesh re-baked with the + /// car at its new stop (shared by every lift kind). + private void CompleteElevatorArrival(ElevatorRuntime el, List riders) + { + var heldOrder = ReleaseRiders(riders); + // Re-bake nav so the car floor welds into the arrival stop and + // riders can walk off. Same swap pattern as region streaming. + var nav = RebuildNavMesh(); + if (nav is not null) + { + _navMesh = nav; + MarkAllObstacles(); + RehomeAllFollowers(nav); + if (_player is not null && _playerFollower is not null) + { + var pos = _player.CurrentTransform.Translation; + var speed = _playerFollower.Speed; + _playerFollower = new SiegeFX.Core.Nav.NavFollower(nav, pos, speed) + { + Traversal = SiegeFX.Core.Nav.NavTraversal.Player, + PartialPathFallback = true, + DiagnosticLogging = true, + }; + } + // Party riders get a fresh follower on the new mesh at + // their ridden position (their old follower still holds + // the pre-ride mesh — see RebuildFollowerBrain remesh). + foreach (var r in riders) + { + if (ReferenceEquals(r, _player) || !r.IsPartyMember || r.IsDead) continue; + var baseStats = r.Actor.Stats; + var combatStats = InjectFollowerWeapon(r.Actor.Template, baseStats) ?? baseStats; + RebuildFollowerBrain(r, combatStats, remesh: true); + } + // ALPHA-2 SHAFT RESCUE — anyone left standing where the + // rebuilt mesh has no floor (the car departed under their + // feet: its tris moved to the far stop, leaving an open + // shaft) gets nudged to the nearest real floor. Without + // this the follower's vertical re-bind gate correctly + // refuses the shaft bottom 12u below — and the player + // freezes over the hole with every click refused. + RescueOffMeshAfterRebuild(nav); + } + riders.Clear(); + if (heldOrder is { } ho) _playerFollower?.SetTarget(ho); + Console.WriteLine($"[elevator] 0x{el.Def.Scid:X8} arrived at stop{el.AtStop}"); + } + + /// Objects placed on a car node ride it (DS1 places everything + /// node-relative; the teleporter's button and its use points sit on the + /// car). Their load-time pose came from the static layout. + private void MoveCarNodeProps(uint carGuid, Matrix4x4 pose) + { + if (_regionLayout is null) return; + Matrix4x4 invLayout = default; + bool haveInv = false; + foreach (var prop in _staticProps) + { + if (prop.NodeGuid != carGuid) continue; + if (!_carPropLocal.TryGetValue(prop, out var local)) + { + if (!haveInv) + { + if (!_regionLayout.TryGetTransform(carGuid, out var layoutPose) || + !Matrix4x4.Invert(layoutPose, out invLayout)) + return; + haveInv = true; + } + local = prop.World * invLayout; + _carPropLocal[prop] = local; + } + prop.World = local * pose; + } + } + + /// After props stream in: put the ones standing on a car where + /// that car is now (they load at the car's authored pose). + private void SyncAllCarNodeProps() + { + foreach (var el in _elevators) + { + int idx = el.CarInstanceIdx; + if (idx >= 0 && idx < _regionInstances.Count && _regionInstances[idx].SnodeGuid == el.Def.CarNodeGuid) + MoveCarNodeProps(el.Def.CarNodeGuid, _regionInstances[idx].World); + else if (_elevatorNodeOverrides.TryGetValue(el.Def.CarNodeGuid, out var p1)) + MoveCarNodeProps(el.Def.CarNodeGuid, p1); + if (el.Def.Instant is not { } spec) continue; + int idx2 = el.Car2InstanceIdx; + if (idx2 >= 0 && idx2 < _regionInstances.Count && _regionInstances[idx2].SnodeGuid == spec.Car2NodeGuid) + MoveCarNodeProps(spec.Car2NodeGuid, _regionInstances[idx2].World); + else if (_elevatorNodeOverrides.TryGetValue(spec.Car2NodeGuid, out var p2)) + MoveCarNodeProps(spec.Car2NodeGuid, p2); + } + } +} diff --git a/src/SiegeFX.Runtime/Render/RenderHost.cs b/src/SiegeFX.Runtime/Render/RenderHost.cs index dc3ca270..3fb5827e 100644 --- a/src/SiegeFX.Runtime/Render/RenderHost.cs +++ b/src/SiegeFX.Runtime/Render/RenderHost.cs @@ -17,7 +17,7 @@ namespace SiegeFX.Runtime.Render; /// Phase 3 just draws a reference grid so WASD + mouse-look can be verified; /// later phases will hang real scene objects off this. /// -public sealed class RenderHost : IDisposable +public sealed partial class RenderHost : IDisposable { private readonly IWindow _window; private readonly string? _meshPath; @@ -496,6 +496,15 @@ private sealed class ElevatorRuntime public int CarInstanceIdx = -1; public Vector3 PrevCarPos; public readonly List Riders = new(); + // SC-ELEVATOR-INSTANT — the hub teleporter's second car and its + // eight dock poses (InstantElevatorPlan.DockIndex); null otherwise. + public Matrix4x4[]? InstantPoses; + public SiegeFX.Core.Assets.InstantRide Ride; + public int Phase; // 0 = rising at the old location, 1 = sinking at the new one + public bool ActionFired; // this phase's actioninfo already sent + public int Car2InstanceIdx = -1; + public Vector3 PrevCar2Pos; + public readonly List Riders2 = new(); } // Delayed fade tuples from movingN_actioninfo's optional 6th field // ("...,in,4.5" = fire 4.5s after departure). @@ -3359,6 +3368,8 @@ internal void PostTriggerWorldMessage(string name, uint fromScid, uint toScid) // we_req_activate at the gizmo from both lever states). if (_elevatorsByScid.TryGetValue(toScid, out var elv)) StartElevatorRide(elv); + else if (_pendingInstantElevators.ContainsKey(toScid)) + RequestInstantElevator(toScid, startRide: true); // SC-STAIRWELL — rotate the hidden crypt stairwell. if (_stairwellsByScid.TryGetValue(toScid, out var sw)) StartStairwellRide(sw); @@ -13588,24 +13599,35 @@ private List PreloadAroundRegion(string centerRegion) // Center's stitch helper drives the new neighbor list. No file = // standalone region (test fixtures) → nothing to extend with. + // SC-ELEVATOR-INSTANT — a not-yet-loaded stitchless region (the + // multiplayer_world new_hub, reached only by teleporter) still + // streams in on its own; it lands as an island (see + // _islandRegionOffsets). + bool streamLoneCenter = !isInitial && !_loadedRegions.ContainsKey(center); var centerStitchPath = center + "/editor/stitch_helper.gas"; + RegionStitchHelper? centerStitches = null; if (!mapReader.TryGetFile(centerStitchPath, out _)) { - if (isInitial) - Console.WriteLine(" neighbor preload: no stitch_helper.gas — standalone region"); - return newlyLoaded; - } - RegionStitchHelper centerStitches; - try { centerStitches = RegionStitchHelper.Load(mapReader.ExtractToMemory(centerStitchPath)); } - catch (Exception ex) - { - Console.WriteLine($" neighbor preload: stitch parse failed for {center} ({ex.Message}) — skipping"); - return newlyLoaded; + if (!streamLoneCenter) + { + if (isInitial) + Console.WriteLine(" neighbor preload: no stitch_helper.gas — standalone region"); + return newlyLoaded; + } } - if (centerStitches.ByDestination.Count == 0) + else { - if (isInitial) Console.WriteLine(" neighbor preload: 0 destination region(s) declared"); - return newlyLoaded; + try { centerStitches = RegionStitchHelper.Load(mapReader.ExtractToMemory(centerStitchPath)); } + catch (Exception ex) + { + Console.WriteLine($" neighbor preload: stitch parse failed for {center} ({ex.Message}) — skipping"); + return newlyLoaded; + } + if (centerStitches.ByDestination.Count == 0 && !streamLoneCenter) + { + if (isInitial) Console.WriteLine(" neighbor preload: 0 destination region(s) declared"); + return newlyLoaded; + } } // Map leaf names ("fh_r2") to full tank paths. @@ -13622,7 +13644,7 @@ private List PreloadAroundRegion(string centerRegion) // not currently in _loadedRegions. var toLoad = new List(); if (!_loadedRegions.ContainsKey(center)) toLoad.Add(center); - foreach (var destLeaf in centerStitches.ByDestination.Keys) + foreach (var destLeaf in centerStitches?.ByDestination.Keys ?? Enumerable.Empty()) { if (!pathByLeaf.TryGetValue(destLeaf, out var np)) continue; if (np == center) continue; @@ -13700,7 +13722,20 @@ bool TryBuildEntry(string rp, out WorldLayout.RegionEntry entry) foreach (var kv in _loadedRegions) entries.Add(kv.Value); var rootHint = (_worldRootRegion != null && _loadedRegions.ContainsKey(_worldRootRegion)) ? _worldRootRegion : center; - var world = WorldLayout.Build(entries, ResolveModel, rootHint); + var world = WorldLayout.Build(entries, ResolveModel, rootHint, _islandRegionOffsets); + // SC-ELEVATOR-INSTANT — a region streamed on its own that no stitch + // from the loaded world reaches (the new_hub teleporter island, or a + // far teleporter destination) is pinned as an island so it gets + // world transforms at all. + if (!isInitial && _loadedRegions.ContainsKey(center) && !world.RegionOffsets.ContainsKey(center)) + { + var sorted = pathByLeaf.Values.OrderBy(v => v, StringComparer.OrdinalIgnoreCase).ToList(); + var offset = IslandRegionOffset(Math.Max(0, sorted.FindIndex(v => string.Equals(v, center, StringComparison.OrdinalIgnoreCase)))); + _islandRegionOffsets[center] = offset; + Console.WriteLine($" [island] '{LeafName(center)}' is not stitched to the loaded world — " + + $"placed at ({offset.M41:F0},{offset.M42:F0},{offset.M43:F0})"); + world = WorldLayout.Build(entries, ResolveModel, rootHint, _islandRegionOffsets); + } _worldLayout = world; // SC-SAVE-REGION (resolver) — a WORLD-WIDE position→region lookup // from the layout every streaming decision already trusts. The @@ -15352,13 +15387,7 @@ private void OnPlayerRegionChanged(string newRegion, bool updateAnchor) if (!_elevatorsByScid.TryGetValue(es.Scid, out var el)) return false; if (el.AtStop != es.AtStop) { - el.AtStop = es.AtStop; - el.Moving = false; - el.T = 0f; - var pose = es.AtStop == 2 ? el.Stop2 : el.Stop1; - el.PrevCarPos = pose.Translation; - _elevatorNodeOverrides[el.Def.CarNodeGuid] = pose; - UpdateElevatorCarInstance(el, pose); + SnapElevatorToStop(el, es.AtStop); structural = true; } return true; @@ -16362,6 +16391,8 @@ void CollectSends(string scidPrefix, string msgPrefix, string baseMsg, List<(uin $"template={kv.Key} expected_tex={v.texName ?? "(none)"}"); } } + // SC-ELEVATOR-INSTANT — objects on a car node ride the car. + SyncAllCarNodeProps(); } /// Phase 21c-3 — restore the single-white-sun fallback that @@ -20658,6 +20689,13 @@ void Add(uint snode, SiegeFX.Core.Assets.RegionGraph.DoorLink link) int carDoor = stop == 1 ? d.CarDoor1Id : d.CarDoor2Id; Add(d.CarNodeGuid, new SiegeFX.Core.Assets.RegionGraph.DoorLink(carDoor, connect, connectDoor)); Add(connect, new SiegeFX.Core.Assets.RegionGraph.DoorLink(connectDoor, d.CarNodeGuid, carDoor)); + // SC-ELEVATOR-INSTANT — the second car docks at the other location. + if (d.Instant is { } inst) + { + var dock = inst.Dock(1, SiegeFX.Core.Assets.InstantElevatorPlan.CarLocation(1, stop), 1); + Add(inst.Car2NodeGuid, new SiegeFX.Core.Assets.RegionGraph.DoorLink(dock.CarDoorId, dock.ConnectGuid, dock.ConnectDoorId)); + Add(dock.ConnectGuid, new SiegeFX.Core.Assets.RegionGraph.DoorLink(dock.ConnectDoorId, inst.Car2NodeGuid, dock.CarDoorId)); + } } // SC-STAIRWELL — each segment welds to the shaft at its CURRENT // configuration socket (the authored nodes.gas link describes only @@ -20713,6 +20751,14 @@ private void LoadElevators(IEnumerable regionPaths) foreach (var def in defs) { if (_elevatorsByScid.ContainsKey(def.Scid)) continue; // streamed twice + if (def.Instant is not null) + { + // SC-ELEVATOR-INSTANT — placed once both locations are + // streamed; until then parked (a press streams the far end). + if (TryPlaceInstantElevator(def, Resolve)) placed++; + else { _pendingInstantElevators[def.Scid] = def; skipped++; } + continue; + } if (!TryGetNodeMeshGuid(def.CarNodeGuid, out var carMesh) || !TryGetNodeMeshGuid(def.Connect1Guid, out var c1Mesh) || !TryGetNodeMeshGuid(def.Connect2Guid, out var c2Mesh)) @@ -20897,15 +20943,22 @@ private void UpdateStairSegmentInstance(StairwellRuntime.Seg seg, Matrix4x4 pose /// Write into the car node's render /// instance (found by snode guid, index cached). AABB recomputed so /// culling follows the platform. - private void UpdateElevatorCarInstance(ElevatorRuntime el, Matrix4x4 pose) + private void UpdateElevatorCarInstance(ElevatorRuntime el, Matrix4x4 pose) => + UpdateCarNodeInstance(el.Def.CarNodeGuid, ref el.CarInstanceIdx, pose); + + /// Car-node render pose + the objects placed on that node (the + /// hub teleporter's button rides its car). Shared by both cars of an + /// instant elevator. + private void UpdateCarNodeInstance(uint carGuid, ref int instanceIdx, Matrix4x4 pose) { - int idx = el.CarInstanceIdx; - if (idx < 0 || idx >= _regionInstances.Count || _regionInstances[idx].SnodeGuid != el.Def.CarNodeGuid) + MoveCarNodeProps(carGuid, pose); + int idx = instanceIdx; + if (idx < 0 || idx >= _regionInstances.Count || _regionInstances[idx].SnodeGuid != carGuid) { idx = -1; for (int i = 0; i < _regionInstances.Count; i++) - if (_regionInstances[i].SnodeGuid == el.Def.CarNodeGuid) { idx = i; break; } - el.CarInstanceIdx = idx; + if (_regionInstances[i].SnodeGuid == carGuid) { idx = i; break; } + instanceIdx = idx; } if (idx < 0) return; // car mesh not streamed — the ride still runs data-side var inst = _regionInstances[idx]; @@ -20916,10 +20969,12 @@ private void UpdateElevatorCarInstance(ElevatorRuntime el, Matrix4x4 pose) /// Riders = player/party members standing on the car when it /// departs — nav-triangle snode match first (exact), AABB fallback for /// anyone whose follower is mid-air/off-mesh. - private void CaptureElevatorRiders(ElevatorRuntime el) + private void CaptureElevatorRiders(ElevatorRuntime el) => + CaptureCarRiders(el.Def.CarNodeGuid, el.CarInstanceIdx, el.Riders); + + private void CaptureCarRiders(uint carGuid, int carInstanceIdx, List riders) { - el.Riders.Clear(); - var carGuid = el.Def.CarNodeGuid; + riders.Clear(); bool OnCar(SiegeFX.Core.Nav.NavFollower? f, Vector3 pos) { if (f is not null && _navMesh is not null) @@ -20929,7 +20984,7 @@ bool OnCar(SiegeFX.Core.Nav.NavFollower? f, Vector3 pos) _navMesh.SourceSnodeGuid[tri] == carGuid) return true; } - int idx = el.CarInstanceIdx; + int idx = carInstanceIdx; if (idx >= 0 && idx < _regionInstances.Count) { var inst = _regionInstances[idx]; @@ -20942,14 +20997,14 @@ bool OnCar(SiegeFX.Core.Nav.NavFollower? f, Vector3 pos) } if (_player is not null && !_player.IsDead && OnCar(_playerFollower, _player.CurrentTransform.Translation)) - el.Riders.Add(_player); + riders.Add(_player); foreach (var m in _party) { if (m.PartyIndex == 0 || m.IsDead) continue; if (OnCar(m.Brain?.Wander?.Follower, m.CurrentTransform.Translation)) - el.Riders.Add(m); + riders.Add(m); } - CarryRiders(el.Riders); + CarryRiders(riders); } private SiegeFX.Core.Nav.NavFollower? RiderFollower(ActorRenderState r) => @@ -21006,6 +21061,7 @@ private void CarryRider(ActorRenderState r, Vector3 delta) private void StartElevatorRide(ElevatorRuntime el) { if (el.Moving) return; + if (el.Def.Instant is not null) { StartInstantElevatorRide(el); return; } el.TargetStop = el.AtStop == 1 ? 2 : 1; el.Moving = true; el.T = 0f; @@ -21998,6 +22054,7 @@ private void TickElevators(float dt) } } + ProcessInstantElevatorRequests(); TickAutoTraps(dt); TickShrines(dt); TickPendingOutFades(dt); @@ -22017,6 +22074,7 @@ private void TickElevators(float dt) foreach (var el in _elevators) { if (!el.Moving) continue; + if (el.Def.Instant is not null) { TickInstantElevator(el, dt); continue; } el.T += dt / MathF.Max(0.25f, el.Def.DurationSeconds); float k = Math.Clamp(el.T, 0f, 1f); k = k * k * (3f - 2f * k); // ease both ends — reads as a winched platform @@ -22039,48 +22097,7 @@ private void TickElevators(float dt) var final = el.AtStop == 1 ? el.Stop1 : el.Stop2; UpdateElevatorCarInstance(el, final); _elevatorNodeOverrides[el.Def.CarNodeGuid] = final; - var heldOrder = ReleaseRiders(el.Riders); - // Re-bake nav so the car floor welds into the arrival stop and - // riders can walk off. Same swap pattern as region streaming. - var nav = RebuildNavMesh(); - if (nav is not null) - { - _navMesh = nav; - MarkAllObstacles(); - RehomeAllFollowers(nav); - if (_player is not null && _playerFollower is not null) - { - var pos = _player.CurrentTransform.Translation; - var speed = _playerFollower.Speed; - _playerFollower = new SiegeFX.Core.Nav.NavFollower(nav, pos, speed) - { - Traversal = SiegeFX.Core.Nav.NavTraversal.Player, - PartialPathFallback = true, - DiagnosticLogging = true, - }; - } - // Party riders get a fresh follower on the new mesh at - // their ridden position (their old follower still holds - // the pre-ride mesh — see RebuildFollowerBrain remesh). - foreach (var r in el.Riders) - { - if (ReferenceEquals(r, _player) || !r.IsPartyMember || r.IsDead) continue; - var baseStats = r.Actor.Stats; - var combatStats = InjectFollowerWeapon(r.Actor.Template, baseStats) ?? baseStats; - RebuildFollowerBrain(r, combatStats, remesh: true); - } - // ALPHA-2 SHAFT RESCUE — anyone left standing where the - // rebuilt mesh has no floor (the car departed under their - // feet: its tris moved to the far stop, leaving an open - // shaft) gets nudged to the nearest real floor. Without - // this the follower's vertical re-bind gate correctly - // refuses the shaft bottom 12u below — and the player - // freezes over the hole with every click refused. - RescueOffMeshAfterRebuild(nav); - } - el.Riders.Clear(); - if (heldOrder is { } ho) _playerFollower?.SetTarget(ho); - Console.WriteLine($"[elevator] 0x{el.Def.Scid:X8} arrived at stop{el.AtStop}"); + CompleteElevatorArrival(el, el.Riders); } } @@ -43794,13 +43811,7 @@ bool ApplyElevatorSnapshot(SiegeFX.Core.Save.ElevatorStopSnapshot es) { if (!_elevatorsByScid.TryGetValue(es.Scid, out var el)) return false; if (el.AtStop == es.AtStop) return false; - el.AtStop = es.AtStop; - el.Moving = false; - el.T = 0f; - var pose = es.AtStop == 2 ? el.Stop2 : el.Stop1; - el.PrevCarPos = pose.Translation; - _elevatorNodeOverrides[el.Def.CarNodeGuid] = pose; - UpdateElevatorCarInstance(el, pose); + SnapElevatorToStop(el, es.AtStop); return true; } diff --git a/tests/SiegeFX.Tests/InstantElevatorTests.cs b/tests/SiegeFX.Tests/InstantElevatorTests.cs new file mode 100644 index 00000000..1766b27e --- /dev/null +++ b/tests/SiegeFX.Tests/InstantElevatorTests.cs @@ -0,0 +1,241 @@ +using System.Numerics; +using System.Text; +using SiegeFX.Core.Actors; +using SiegeFX.Core.Assets; +using SiegeFX.Core.Tank; + +namespace SiegeFX.Tests; + +public class InstantElevatorTests +{ + const string Region = "/world/maps/multiplayer_world/regions/town_center"; + + // town_center's transport stone as shipped (objects/regular/elevator.gas), + // plus a farm-style 2-stop lift to prove the old form still parses. + const string ElevatorGas = """ + [t:elevator_instant_4s_1c,n:0x03200c7a] + { + [elevator_instant_4s_1c] + { + i connect_door_location1_level1 = 9; + i connect_door_location1_level2 = 11; + i connect_door_location2_level1 = 20; + i connect_door_location2_level2 = 18; + x connect_node_location1 = 0xF81ABFCE; + x connect_node_location2 = 0xD14939A3; + f duration_location1_moving_to_lv1 = 16; + f duration_location2_moving_to_lv1 = 16; + i e2_connect_door_location1_level1 = 10; + i e2_connect_door_location1_level2 = 12; + i e2_connect_door_location2_level1 = 19; + i e2_connect_door_location2_level2 = 17; + i e2_elevator_door_location1_level1 = 2; + i e2_elevator_door_location1_level2 = 2; + i e2_elevator_door_location2_level1 = 2; + i e2_elevator_door_location2_level2 = 2; + x e2_elevator_node = 0xA9CAB56D; + x elevator_node = 0x07C15973; + location1_moving_to_lv1_actioninfo = "multiplayer_world_town_center_teleport"; + location1_moving_to_lv2_actioninfo = "multiplayer_world_teleport"; + location2_moving_to_lv1_actioninfo = "multiplayer_world_new_hub_teleport"; + location2_moving_to_lv2_actioninfo = "multiplayer_world_teleport"; + material = "hub"; + x other_elevator = 0x07F00038; + } + [placement] + { + q orientation = 0,-0.707107,0,0.707107; + p position = 0,5,0,0x07c15973; + } + } + [t:elevator_2s_1c_2n,n:0x081000cb] + { + [elevator_2s_1c_2n] + { + x elevator_node = 0x59DD9E68; + x connect_node_level1 = 0x11111111; + x connect_node_level2 = 0x22222222; + i connect_door_level1 = 3; + i connect_door_level2 = 4; + f duration = 4; + } + [placement] + { + p position = 0,0,0,0x59dd9e68; + } + } + """; + + static IReadOnlyList LoadDefs(string gas, out IReadOnlyList diags) + { + var writer = new TankWriter { Priority = TankPriority.Factory }; + writer.Add(Region + "/objects/regular/elevator.gas", Encoding.UTF8.GetBytes(gas)); + writer.Add(Region + "/objects/regular/actor.gas", "[a] { x = 1; }"u8.ToArray()); // world-level folder probe + var path = Path.Combine(Path.GetTempPath(), $"siegefx-elevator-{Guid.NewGuid():N}.dsmap"); + try + { + writer.Write(path, new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc)); + using var tank = TankFile.Open(path); + var (defs, d) = ElevatorStore.Load(new TankReader(tank), Region); + diags = d; + return defs; + } + finally { File.Delete(path); } + } + + static ElevatorDef TownCenterStone() + { + var defs = LoadDefs(ElevatorGas, out var diags); + Assert.Empty(diags); + return Assert.Single(defs, d => d.Scid == 0x03200C7A); + } + + [Fact] + public void TheTownTeleporterParsesBothCarsAndBothLocations() + { + var def = TownCenterStone(); + var spec = Assert.IsType(def.Instant); + Assert.Equal(0x07C15973u, def.CarNodeGuid); + Assert.Equal(0xA9CAB56Du, spec.Car2NodeGuid); + Assert.Equal(0x07F00038u, spec.OtherElevatorScid); + // Car 1 (elevator_node): unset elevator doors default to 1. + Assert.Equal(new InstantDock(0xF81ABFCE, 9, 1), spec.Dock(0, 1, 1)); + Assert.Equal(new InstantDock(0xF81ABFCE, 11, 1), spec.Dock(0, 1, 2)); + Assert.Equal(new InstantDock(0xD14939A3, 20, 1), spec.Dock(0, 2, 1)); + Assert.Equal(new InstantDock(0xD14939A3, 18, 1), spec.Dock(0, 2, 2)); + // Car 2 (e2_elevator_node) docks with its door 2. + Assert.Equal(new InstantDock(0xF81ABFCE, 10, 2), spec.Dock(1, 1, 1)); + Assert.Equal(new InstantDock(0xD14939A3, 17, 2), spec.Dock(1, 2, 2)); + // The base record describes car 1's level-1 stops. + Assert.Equal((0xF81ABFCEu, 9, 1), (def.Connect1Guid, def.Connect1DoorId, def.CarDoor1Id)); + Assert.Equal((0xD14939A3u, 20, 1), (def.Connect2Guid, def.Connect2DoorId, def.CarDoor2Id)); + // The skrit's 2s rise default, the authored 16s sink. + Assert.Equal(new[] { 2f, 2f }, spec.AscendSeconds); + Assert.Equal(new[] { 16f, 16f }, spec.DescendSeconds); + Assert.Equal("hub_plate_up", spec.UpEffect); + Assert.Equal(0x07C15973u, spec.GizmoNodeGuid); + Assert.Equal(new Vector3(0, 5, 0), spec.GizmoLocalPosition); + } + + [Fact] + public void TwoStopLiftsStillParseWithoutTheInstantPart() + { + var lift = Assert.Single(LoadDefs(ElevatorGas, out _), d => d.Scid == 0x081000CB); + Assert.Null(lift.Instant); + Assert.Equal((0x11111111u, 3), (lift.Connect1Guid, lift.Connect1DoorId)); + Assert.Equal((0x22222222u, 4), (lift.Connect2Guid, lift.Connect2DoorId)); + Assert.Equal(4f, lift.DurationSeconds); + } + + [Fact] + public void AnInstantElevatorMissingAConnectDoorIsSkippedWithADiagnostic() + { + var gas = ElevatorGas.Replace("i e2_connect_door_location2_level2 = 17;", ""); + var defs = LoadDefs(gas, out var diags); + Assert.DoesNotContain(defs, d => d.Scid == 0x03200C7A); + Assert.Contains(diags, d => d.Contains("e2_connect_door_location2_level2")); + } + + [Fact] + public void ARideFromTheTownRisesThereSwapsAndSinksInTheHub() + { + var spec = TownCenterStone().Instant!; + var ride = InstantElevatorPlan.Ride(spec, fromStop: 1); + Assert.Equal((1, 2), (ride.FromStop, ride.ToStop)); + Assert.Equal(2f, ride.AscendSeconds); // duration_location1_moving_to_lv2 + Assert.Equal(16f, ride.DescendSeconds); // duration_location2_moving_to_lv1 + // The town car rises in the town and lands in the hub ... + Assert.Equal((1, 2), (ride.Car1.AscendLocation, ride.Car1.ArriveLocation)); + Assert.Equal("multiplayer_world_teleport", ride.Car1.AscendActionInfo); + Assert.Equal("multiplayer_world_new_hub_teleport", ride.Car1.ArriveActionInfo); + // ... while the hub car makes the opposite trip. + Assert.Equal((2, 1), (ride.Car2.AscendLocation, ride.Car2.ArriveLocation)); + Assert.Equal("multiplayer_world_town_center_teleport", ride.Car2.ArriveActionInfo); + Assert.Equal(0.5f, InstantRide.ActionDelay(ride.DescendSeconds)); + Assert.Equal(0.25f, InstantRide.ActionDelay(0.5f)); + } + + [Fact] + public void TheReturnRideMirrorsIt() + { + var spec = TownCenterStone().Instant!; + var ride = InstantElevatorPlan.Ride(spec, fromStop: 2); + Assert.Equal((2, 1), (ride.FromStop, ride.ToStop)); + Assert.Equal((2, 1), (ride.Car1.AscendLocation, ride.Car1.ArriveLocation)); + Assert.Equal((1, 2), (ride.Car2.AscendLocation, ride.Car2.ArriveLocation)); + Assert.Equal("multiplayer_world_town_center_teleport", ride.Car1.ArriveActionInfo); + // Cars always sit at opposite locations. + Assert.Equal(1, InstantElevatorPlan.CarLocation(0, 1)); + Assert.Equal(2, InstantElevatorPlan.CarLocation(1, 1)); + Assert.Equal(2, InstantElevatorPlan.CarLocation(0, 2)); + Assert.Equal(1, InstantElevatorPlan.CarLocation(1, 2)); + } + + // ── the button trigger: pressed AND someone on the platform ───────────── + sealed class NodeCtx : TriggerContext + { + public bool OnPlatform; + public readonly List Sent = new(); + public override bool PartyMemberWithinNode(uint regionGuid, int nodeSection, int nodeLevel, int nodeObject) + => OnPlatform && regionGuid == 0xD5A3ACD9 && nodeSection == 4; + public override void PostWorldMessage(string name, uint fromScid, uint toScid) => Sent.Add(toScid); + } + + static TriggerRuntime ButtonTrigger() + { + // trigger_snd_msg_activate 0x03200cae, town_center special.gas. + var doc = GasDocument.Parse(""" + [common] { [instance_triggers] { + [*] { + action* = send_world_message("we_req_activate",0x03200C7A,0f,"default","",0); + condition* = receive_world_message("we_req_activate",0,"on_every_message"); + condition* = party_member_within_node(0xD5A3ACD9,4,-1,-1,"wait_for_every_message"); + b multi_player = true; b single_player = true; b single_shot = false; + } + } } + """); + var rows = doc.Roots.Single().Children.Single().Children; + var matrix = new TriggerMatrix(rows.Select(r => TriggerRow.Parse(r, "test", null)).ToList()); + var rt = new TriggerRuntime(); + rt.Register(new TriggerInstance(0x03200CAE, 0, Vector3.Zero, matrix, startActive: true)); + return rt; + } + + [Fact] + public void SteppingOntoThePlatformDoesNotLaunchTheTeleporter() + { + var rt = ButtonTrigger(); + var ctx = new NodeCtx(); + rt.Tick(0.1, ctx); + ctx.OnPlatform = true; + rt.Tick(0.1, ctx); + rt.Tick(0.1, ctx); + Assert.Empty(ctx.Sent); + } + + [Fact] + public void PressingTheButtonOnThePlatformLaunchesItEveryTime() + { + var rt = ButtonTrigger(); + var ctx = new NodeCtx { OnPlatform = true }; + for (int press = 0; press < 3; press++) + { + rt.PostInboundMessage(0x03200CAE, "we_req_activate"); + rt.Tick(0.1, ctx); + rt.Tick(0.1, ctx); + } + Assert.Equal(new uint[] { 0x03200C7A, 0x03200C7A, 0x03200C7A }, ctx.Sent); + } + + [Fact] + public void PressingTheButtonWithNobodyOnThePlatformDoesNothing() + { + var rt = ButtonTrigger(); + var ctx = new NodeCtx(); + rt.PostInboundMessage(0x03200CAE, "we_req_activate"); + rt.Tick(0.1, ctx); + ctx.OnPlatform = true; // the dropped press does not linger + rt.Tick(0.1, ctx); + Assert.Empty(ctx.Sent); + } +}