diff --git a/README.md b/README.md index 589a985..9ae90ab 100644 --- a/README.md +++ b/README.md @@ -297,7 +297,7 @@ only, no authorization - with its live status in the launcher's status bar. Poin with `claude mcp add --transport http illusion http://127.0.0.1:2010/mcp`; change the port with `McpPort` in settings. -It serves 86 tools. The file tools all read through the same format layer the editor uses, so what +It serves 92 tools. The file tools all read through the same format layer the editor uses, so what a model is told about a file is what the toolkit itself sees; the editor tools drive the running map editor itself. @@ -306,7 +306,7 @@ map editor itself. | **Archives** | `list_sds_files`, `open_sds_file`, `get_sds_header`, `list_resources`, `get_resource_info`, `search_resources`, `extract_resource`, `get_sds_stats`, `close_sds_file` | | **Decoding** | `decode_resource` (extract + decode in one call), `decode_actors`, `decode_frame_resource`, `decode_itemdesc`, `decode_collisions` | | **Scripts** | `decompile_script_resource`, `decompile_lua` - the game's compiled Lua back to source | -| **Materials** | `open_mtl_file`, `list_mtl_files`, `get_material_info`, `search_materials` | +| **Materials** | `open_mtl_file`, `list_mtl_files`, `get_material_info`, `search_materials`, `archive_materials` | | **Textures** | `list_sds_textures`, `inspect_sds_texture`, `inspect_dds_file`, `inspect_dds_bytes` | | **Tables** | `list_tables`, `dump_rows`, `lookup_by_row` | | **Stream map** | `parse_stream_map`, `edit_stream_map` | @@ -314,6 +314,7 @@ map editor itself. | **Utility** | `hash_fnv32`, `hash_fnv64`, `hash_batch`, `convert_number`, `detect_file_format`, `detect_format_from_bytes`, `list_game_files`, `get_configured_games`, `ping` | | **Editor** | `editor_status`, `editor_list_areas`, `editor_open_area`, `editor_save`, `editor_build`, `editor_mirror_winter`, `editor_undo`, `editor_redo`, `editor_notices` | | **Scene** | `scene_find`, `scene_select`, `scene_delete_selected`, `scene_duplicate_selected`, `object_move`, `object_properties`, `object_set_property`, `actor_import`, `object_import`, `mesh_hide_triangles`, `mesh_materials`, `collision_unused_hulls`, `crash_placements` | +| **Interiors** | `shop_places`, `shop_place_add`, `shop_place_delete`, `shop_create`, `shop_delete` | | **Resource editor** | `editor_target`, `resource_list`, `resource_open`, `resource_status`, `car_tuning`, `car_tuning_set` | | **Cars** | `car_clone`, `car_substitute`, `car_export_m2o`, `archive_build` | | **Loading zones** | `zones_at`, `zones_map`, `zone_move_face`, `zone_create`, `zone_delete` | @@ -350,6 +351,20 @@ district in the table or two - and inside one named `AREA902_FOOXBAR` he does no editor open the new zone is a step of its history; `zone_delete` takes an added zone out again (a zone the game ships with is refused). Zones are made in the base game's `city_univers` only. +**Interiors.** The game stands a shop, a diner or a flat at a place through three things: a marker +frame inside the interior's archive under `shops\`, a pair of box volumes in `city_univers` that +load it and let it go, and rows of `missions\SHOPS\cityshops.bin`. `shop_places` lists the +interiors and the places each stands at, `shop_place_add` stands one at another place and +`shop_place_delete` takes a place out. `shop_create` makes a new interior as a copy of an existing +one under its own name with its own row, and `shop_delete` removes it. A save is all or nothing. +`object_import` takes a parent frame and, like `actor_import`, works on the archive open in the +resource editor, which is how an interior is furnished and lit. + +`archive_materials` tells each material an archive is drawn with as the game's own, changed or +added, with its definition in full and which of its textures the archive holds. The game's own are +an embedded list of the materials it ships with, so the answer is the same on any install. +`libraryTo` writes the added and changed ones as a material library of their own. + **Cars.** `car_clone` makes a new car out of an existing one for single player: a copy of its archive (and the winter `_z` twin) with the root frame, name table, prefab entry, entity data and geometry buffers filed under the new model name, registered in the vehicle, paint, cover-point and diff --git a/src/Illusion.Assets/Frames/FrameTransplant.cs b/src/Illusion.Assets/Frames/FrameTransplant.cs index 5da3bb6..c1d732c 100644 --- a/src/Illusion.Assets/Frames/FrameTransplant.cs +++ b/src/Illusion.Assets/Frames/FrameTransplant.cs @@ -60,6 +60,10 @@ public sealed class TransplantedObject internal readonly Dictionary Copies = new(); // source → copy internal FrameHeaderScene? Anchor; + /// The frame of the receiving scene the copy's root was hung under, when it was asked to be + /// someone's child; null for a root that stands in the scene by itself. + public FrameObjectBase? Under { get; internal set; } + /// The copy's root — what an actor places, or the scenery object itself. public FrameObjectBase Root { get; internal set; } = null!; @@ -206,7 +210,18 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => /// public static TransplantedObject? TryTransplant(ISceneDocument document, FrameResource source, FrameObjectBase root, string name, Standing standing, Matrix4x4 world, ImportGeometry? shared, - out string? skipReason) + out string? skipReason) => + TryTransplant(document, source, root, name, standing, world, shared, under: null, out skipReason); + + /// + /// The same, with the copy's root hung under — a frame of the receiving scene — + /// instead of standing in the scene by itself: the shape an interior's furniture ships in, every piece a child + /// of the frame that carries the interior to its place. is still where the copy + /// stands in the world; its own matrix is worked out against the parent's. Scenery only. + /// + public static TransplantedObject? TryTransplant(ISceneDocument document, FrameResource source, + FrameObjectBase root, string name, Standing standing, Matrix4x4 world, ImportGeometry? shared, + FrameObjectBase? under, out string? skipReason) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(root); @@ -223,6 +238,25 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => return null; } if (!CanTransplant(root, out skipReason)) return null; + Matrix4x4 underInverse = Matrix4x4.Identity; + if (under != null) + { + if (standing != Standing.Scenery) + { + skipReason = "only scenery can be hung under a frame — an actor places its own object"; + return null; + } + if (!resource.FrameObjects.TryGetValue(under.RefID, out object? held) || !ReferenceEquals(held, under)) + { + skipReason = "the parent is not a frame of the receiving scene"; + return null; + } + if (!Matrix4x4.Invert(under.WorldTransform, out underInverse)) + { + skipReason = $"'{under.Name}' has a matrix that cannot be undone — nothing can be placed under it"; + return null; + } + } if (string.IsNullOrWhiteSpace(name) || resource.FrameObjects.Values.OfType() .Any(o => string.Equals(o.Name.String, name, StringComparison.OrdinalIgnoreCase))) { @@ -244,7 +278,17 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => var subtree = new List(); Collect(root, subtree, new HashSet()); - var result = new TransplantedObject { Resource = resource, Adapter = adapter, Anchor = anchor }; + var result = new TransplantedObject { Resource = resource, Adapter = adapter, Anchor = anchor, Under = under }; + // What a child of `under` records in its second slot: the top of the parent's chain hangs off it — an + // object, or the scene folder that holds that top; nothing when the chain ends at a rootless frame. + FrameEntry? underAnchor = null; + if (under != null) + { + FrameObjectBase top = under; + var climbed = new HashSet { top }; + while (top.Parent is { } next && climbed.Add(next)) top = next; + underAnchor = top.Root as FrameEntry ?? resource.FrameScenes.Values.FirstOrDefault(folder => folder.Children.Contains(top)); + } string unique = Guid.NewGuid().ToString("N")[..8]; var reused = new HashSet(); // source buffers this copy draws from without copying them if (!CopyBuffers(source, resource, subtree, name, unique, result, shared, reused, @@ -295,7 +339,10 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => FrameObjectBase copy = result.Copies[original]; if (ReferenceEquals(original, root)) { - result.Parents[copy] = (null, anchor); + // Under a frame: the parent in the first slot and, in the second, what the parent's own chain + // hangs off — the way a shipped interior's pieces name both the frame that carries them and + // the scene that frame stands in. + result.Parents[copy] = under != null ? (under, underAnchor) : (null, anchor); continue; } // Inside the subtree a link follows its target's copy. A second-slot link that pointed OUTSIDE it @@ -303,12 +350,27 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => // whatever the root hangs off, or the root itself when it hangs off nothing. FrameEntry? parent1 = Mapped(original.Parent, result.Copies); FrameEntry? parent2 = original.Refs.ContainsKey(FrameEntryRefTypes.Parent2) - ? Mapped(original.Root, result.Copies) ?? (FrameEntry?)anchor ?? result.Root + ? Mapped(original.Root, result.Copies) ?? (under != null ? underAnchor : anchor) ?? result.Root : null; result.Parents[copy] = (parent1, parent2); } - if (standing == Standing.Scenery) + if (under != null) + { + // A child is found through its parent: not on the spawn list, and a matrix of its own that is told + // against the parent's. The anchored-mesh bit as the shipped files have it on a child: set when the + // second slot names an object (a door's leaf under its frame), clear when it names the scene (an + // interior's furniture under its holder) or nothing. + result.Root.IsOnFrameTable = false; + if (result.Root is FrameObjectSingleMesh childMesh) + { + childMesh.SingleMeshFlags = underAnchor is FrameObjectBase + ? childMesh.SingleMeshFlags | SingleMeshFlags.ParentIndex2_Flag + : childMesh.SingleMeshFlags & ~SingleMeshFlags.ParentIndex2_Flag; + } + result.Root.LocalTransform = world * underInverse; + } + else if (standing == Standing.Scenery) { // The stock drawable shape, as the bridge's object factory builds it: anchored through the // second slot, on the spawn list, normal-season flags, and the anchored-mesh bit a mesh in that @@ -356,16 +418,89 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => /// shipped ones stand on it. Null when nothing in the subtree decodes. /// /// - public static Vector3? BaseOf(FrameObjectBase root) => - BoundsOf(root) is { } bounds + public static Vector3? BaseOf(FrameObjectBase root) => BaseOf(root, Quaternion.Identity); + + // The same point with the root's space tilted first — the bottom of the object as it STANDS (see StandAt). + private static Vector3? BaseOf(FrameObjectBase root, Quaternion tilt) => + BoundsOf(root, Matrix4x4.CreateFromQuaternion(tilt)) is { } bounds ? new Vector3((bounds.Min.X + bounds.Max.X) / 2f, (bounds.Min.Y + bounds.Max.Y) / 2f, bounds.Min.Z) : null; + /// + /// A rotation as two: the tilt that stands the object the way it stands, and after it the heading — its + /// turn about the vertical axis. rotation = Quaternion.Concatenate(Tilt, heading). + /// + /// An object that is upright in its own space has no tilt and its rotation IS its heading. One that is not + /// — the chairs of Francesca's flat are modelled with their height along the mesh's y, 0.56 × 0.91 × 0.50 + /// in their own box, and stood up by a quarter turn in the frame's matrix — keeps that quarter turn in the + /// tilt, whichever way it faces. + /// + /// + public static (Quaternion Tilt, float Heading) SplitHeading(Quaternion rotation) + { + // The part of the rotation that is a turn about z. None can be told for an object standing exactly + // on its head: there every heading is the same rotation about some horizontal axis. + float length = MathF.Sqrt((rotation.Z * rotation.Z) + (rotation.W * rotation.W)); + if (length < 1e-6f) return (rotation, 0f); + var twist = new Quaternion(0f, 0f, rotation.Z / length, rotation.W / length); + float heading = 2f * MathF.Atan2(twist.Z, twist.W); + return (Quaternion.Normalize(Quaternion.Concatenate(rotation, Quaternion.Conjugate(twist))), heading); + } + + /// A rotation with its heading replaced and its tilt kept: what an asked heading makes of the + /// rotation an object has in its own archive. For an upright object that is the heading itself. + public static Quaternion WithHeading(Quaternion rotation, float yawRadians) => + Quaternion.Concatenate(SplitHeading(rotation).Tilt, Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yawRadians)); + + /// + /// The matrix that STANDS a piece of scenery on a point: tilted and scaled as it is in its own archive + /// (), turned about the vertical axis the way it is there or — when a heading + /// is asked for — to that heading, and moved so that the middle of the bottom of its geometry is on + /// . + /// + /// The heading replaces the object's heading and nothing else. Replacing its whole rotation, as this once + /// did, laid on its side everything that is not upright in its own space (see + /// ). The bottom is likewise the bottom of the object as it stands — its + /// geometry with the tilt applied — and not of the box of its own space, of which such an object's bottom + /// is a side. + /// + /// + /// The heading to give it; null keeps the one it has. + public static Matrix4x4 StandAt(FrameObjectBase root, Matrix4x4 sourceWorld, Vector3 at, float? yawRadians) + { + // A matrix that is not rotation and scale (sheared, flattened) has no tilt to keep: it is taken as it + // is, and an asked heading is then all the rotation the copy gets. + bool split = TransformMath.TryDecompose(sourceWorld, out Vector3 scale, out Quaternion rotation, out _); + (Quaternion tilt, float heading) = split ? SplitHeading(rotation) : (Quaternion.Identity, 0f); + + Matrix4x4 world = sourceWorld; + // What follows the tilt: the turn about the vertical axis and the scale. + Matrix4x4 standing = sourceWorld; + if (yawRadians is { } yaw) + { + Quaternion turn = Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw); + world = TransformMath.Compose(Quaternion.Concatenate(tilt, turn), scale, Vector3.Zero); + standing = TransformMath.Compose(turn, scale, Vector3.Zero); + } + else if (split) + { + standing = TransformMath.Compose( + Quaternion.CreateFromAxisAngle(Vector3.UnitZ, heading), scale, Vector3.Zero); + } + + Vector3 standsOn = BaseOf(root, tilt) ?? Vector3.Zero; + world.Translation = at - Vector3.TransformNormal(standsOn, standing); + return world; + } + /// The box everything the subtree draws fits in, in its root's own space. Null when nothing decodes. - public static (Vector3 Min, Vector3 Max)? BoundsOf(FrameObjectBase root) + public static (Vector3 Min, Vector3 Max)? BoundsOf(FrameObjectBase root) => BoundsOf(root, Matrix4x4.Identity); + + private static (Vector3 Min, Vector3 Max)? BoundsOf(FrameObjectBase root, Matrix4x4 then) { ArgumentNullException.ThrowIfNull(root); if (!Matrix4x4.Invert(root.WorldTransform, out Matrix4x4 toRoot)) return null; + toRoot *= then; Vector3 min = new(float.MaxValue), max = new(float.MinValue); foreach ((Vector3[] positions, _) in TrianglesOf(root)) { diff --git a/src/Illusion.Assets/Frames/RigBones.cs b/src/Illusion.Assets/Frames/RigBones.cs new file mode 100644 index 0000000..a1507c5 --- /dev/null +++ b/src/Illusion.Assets/Frames/RigBones.cs @@ -0,0 +1,205 @@ +using System.Numerics; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Hashing; +using Illusion.Formats.Mathematics; + +namespace Illusion.Assets.Frames; + +/// +/// Adds a bone to the rig of a skinned model. +/// +/// +/// A rig keeps its bones in LEVEL order - the root, its children, their children - with each bone's children +/// standing together in the order of their parents, and says so twice: a parent index per bone, and a running +/// "last child so far" index per bone. So a new bone has exactly one place: after its parent's last child. Every +/// bone behind that place moves up by one, and with it every table that names a bone by its index - the parent +/// indices themselves, the remap pools of the blend info, the frames hung on bones. What the skeleton says a +/// second time about the pools is derived again (). +/// +/// +/// Read off the game's own data: the police Smith is the civilian Smith with two bones added, and its tables +/// differ from the civilian's in exactly these ways. Every assumption made here about a table is CHECKED +/// against the model first, and a model that does not fit is refused untouched. +/// +/// +public static class RigBones +{ + private const float Tolerance = 2e-3f; + + /// + /// Adds a bone under , standing at a place in the model's space with no turn of + /// its own. Nothing is skinned to it yet; is how far the geometry it will carry + /// reaches from it on each axis (the box the engine keeps per bone). + /// + /// Where the bone stands in the rig afterwards. + /// Null on success, otherwise why not; the model is unchanged on a refusal. + public static string? Insert(FrameObjectModel model, string name, string parent, Vector3 at, Vector3 reach, out int index) + { + ArgumentNullException.ThrowIfNull(model); + index = -1; + if (string.IsNullOrWhiteSpace(name) || name.Any(c => c is < ' ' or > '~')) return "a bone's name is plain Latin text"; + if (!float.IsFinite(at.X) || !float.IsFinite(at.Y) || !float.IsFinite(at.Z)) return "the place must be finite numbers"; + + FrameSkeleton skeleton; + FrameSkeletonHierarchy hierarchy; + FrameBlendInfo blend; + try + { + skeleton = model.GetSkeletonObject(); + hierarchy = model.GetSkeletonHierarchyObject(); + blend = model.GetBlendInfoObject(); + } + catch (Exception ex) when (ex is InvalidOperationException or NullReferenceException or KeyNotFoundException) + { + return "the model has no rig that can be read: " + ex.Message; + } + + HashName[] names = skeleton.BoneNames ?? []; + int n = names.Length; + if (n == 0) return "the model has no bones"; + if (n >= 254) return "the rig is full: a bone is named by one byte"; + if (names.Any(b => string.Equals(b.String, name, StringComparison.OrdinalIgnoreCase))) return $"the rig already has a bone named '{name}'"; + int p = Array.FindIndex(names, b => string.Equals(b.String, parent, StringComparison.OrdinalIgnoreCase)); + if (p < 0) return $"the rig has no bone named '{parent}'"; + + // ---- the shape this code knows how to extend, checked table by table + byte[] parents = hierarchy.ParentIndices ?? []; + byte[] lastChild = hierarchy.LastChildIndices ?? []; + byte[] chain = hierarchy.UnkData ?? []; + Matrix4x4[] joint = skeleton.JointTransforms ?? [], world = skeleton.WorldTransforms ?? [], rest = model.RestTransform ?? []; + FrameSkeleton.MappingForBlendingInfo[] maps = skeleton.MappingForBlendingInfos ?? []; + FrameBlendInfo.BoneTransform[] poses = blend.BoneTransforms ?? []; + if (parents.Length != n || lastChild.Length != n || joint.Length != n || world.Length != n || rest.Length != n) + return "the rig's per-bone tables are not all as long as its list of bones"; + if (skeleton.NumBones.Any(count => count != n)) return "the rig counts its bones differently per level"; + if (skeleton.NumUnkCount2 != n) return "the rig's second bone count is not its number of bones"; + if (maps.Any(m => (m.Bounds?.Length ?? 0) != n)) return "a level's per-bone boxes are not as many as the bones"; + if (poses.Length != n - 1) return "the blend info does not hold one pose for every bone but the root"; + if (!chain.AsSpan().SequenceEqual(Chain(n))) return "the hierarchy's chain is not the plain run of indices this was written for"; + if (parents[0] != byte.MaxValue) return "the first bone is not the root"; + for (int i = 1; i < n; i++) + { + if (parents[i] >= i) return $"bone {i} stands before its parent - the rig is not in level order"; + if (i > 1 && parents[i] < parents[i - 1]) return $"bone {i}'s parent comes before bone {i - 1}'s - the rig is not in level order"; + } + if (!lastChild.AsSpan().SequenceEqual(LastChildren(parents))) return "the hierarchy's last-child run is not what its parents imply"; + for (int i = 1; i < n; i++) + { + if (!Close(Whole(joint[i]) * Whole(rest[parents[i]]), Whole(rest[i]))) + return $"bone {i} ({names[i].String}) does not stand where its joint and its parent put it"; + // the bone the rig is scaled by keeps an empty world matrix on every car; the others the inverse of the rest + if (world[i] != default && !Close(Whole(world[i]) * Whole(rest[i]), Matrix4x4.Identity)) + return $"bone {i} ({names[i].String}) has a world matrix that is not the inverse of its rest"; + if (!Close(Whole(poses[i - 1].Transform), Whole(world[i]))) + return $"the blend info's pose {i - 1} is not bone {i}'s world matrix"; + } + + // ---- the new bone + int k = lastChild[p] + 1; // after its parent's last child (or where one would stand) + Matrix4x4 like = rest[n - 1]; // the fourth column, as this rig stores it + Matrix4x4 newRest = Stored(Matrix4x4.CreateTranslation(at), like); + if (!Matrix4x4.Invert(Whole(rest[p]), out Matrix4x4 parentInverse)) return $"'{parent}' has a transform that cannot be inverted"; + Matrix4x4 newJoint = Stored(Matrix4x4.CreateTranslation(at) * parentInverse, like); + Matrix4x4 newWorld = Stored(Matrix4x4.CreateTranslation(-at), like); + var box = new BoundingBox(-Vector3.Abs(reach), Vector3.Abs(reach)); + + byte Moved(byte bone) => bone != byte.MaxValue && bone >= k ? (byte)(bone + 1) : bone; + + byte[] newParents = [.. parents[..k].Select(Moved), (byte)p, .. parents[k..].Select(Moved)]; + hierarchy.ParentIndices = newParents; + hierarchy.LastChildIndices = LastChildren(newParents); + hierarchy.UnkData = Chain(n + 1); + + skeleton.BoneNames = [.. names[..k], new HashName(name), .. names[k..]]; + skeleton.JointTransforms = [.. joint[..k], newJoint, .. joint[k..]]; + skeleton.WorldTransforms = [.. world[..k], newWorld, .. world[k..]]; + skeleton.NumBones = [.. skeleton.NumBones.Select(count => count + 1)]; + skeleton.NumUnkCount2 = n + 1; + for (int level = 0; level < maps.Length; level++) + { + FrameSkeleton.MappingForBlendingInfo map = maps[level]; + BoundingBox[] boxes = map.Bounds ?? []; + // the first level is the one the geometry will be on; a level that does not draw the bone keeps an empty box + map.Bounds = [.. boxes[..k], level == 0 ? box : new BoundingBox(Vector3.Zero, Vector3.Zero), .. boxes[k..]]; + maps[level] = map; + } + skeleton.MappingForBlendingInfos = maps; + + model.RestTransform = [.. rest[..k], newRest, .. rest[k..]]; + foreach (FrameObjectModel.AttachmentReference hung in model.AttachmentReferences ?? []) hung.JointIndex = Moved(hung.JointIndex); + + var pose = new FrameBlendInfo.BoneTransform { Transform = newWorld, Bounds = box, IsValid = poses.Length > 0 ? poses[^1].IsValid : byte.MaxValue }; + blend.BoneTransforms = [.. poses[..(k - 1)], pose, .. poses[(k - 1)..]]; + FrameBlendInfo.BoneIndexInfo[] levels = blend.BoneIndexInfos ?? []; + for (int level = 0; level < levels.Length; level++) + { + FrameBlendInfo.BoneIndexInfo info = levels[level]; + info.BoneRemapIDs = [.. (info.BoneRemapIDs ?? []).Select(Moved)]; + levels[level] = info; + } + blend.BoneIndexInfos = levels; + + // what the skeleton says about the pools - usage, references, level masks - follows the new numbering + if (!BlendPoolTables.Sync(model)) return "the bone is in, but the skeleton's account of the pools could not be rebuilt"; + index = k; + return null; + } + + // [count, 1, 2, ..., count - 1, 0]: what the hierarchy keeps beside its parents on every car looked at. + private static byte[] Chain(int count) + { + var chain = new byte[count + 1]; + chain[0] = (byte)count; + for (int i = 1; i < count; i++) chain[i] = (byte)i; + chain[count] = 0; + return chain; + } + + // For each bone, the index of the last bone that is a child of it or of any bone before it. + private static byte[] LastChildren(byte[] parents) + { + var last = new byte[parents.Length]; + int running = 0; + int child = 1; + for (int bone = 0; bone < parents.Length; bone++) + { + while (child < parents.Length && parents[child] <= bone) + { + running = child; + child++; + } + last[bone] = (byte)running; + } + return last; + } + + // A frame matrix is kept as three columns; the fourth is whatever the file holds. For arithmetic it is a + // whole matrix, and for storing it takes the fourth column of a matrix of the same rig. + private static Matrix4x4 Whole(Matrix4x4 m) + { + m.M14 = 0f; + m.M24 = 0f; + m.M34 = 0f; + m.M44 = 1f; + return m; + } + + private static Matrix4x4 Stored(Matrix4x4 m, Matrix4x4 like) + { + m.M14 = like.M14; + m.M24 = like.M24; + m.M34 = like.M34; + m.M44 = like.M44; + return m; + } + + private static bool Close(Matrix4x4 a, Matrix4x4 b) + { + Matrix4x4 d = a - b; + return MathF.Abs(d.M11) < Tolerance && MathF.Abs(d.M12) < Tolerance && MathF.Abs(d.M13) < Tolerance + && MathF.Abs(d.M21) < Tolerance && MathF.Abs(d.M22) < Tolerance && MathF.Abs(d.M23) < Tolerance + && MathF.Abs(d.M31) < Tolerance && MathF.Abs(d.M32) < Tolerance && MathF.Abs(d.M33) < Tolerance + && MathF.Abs(d.M41) < Tolerance && MathF.Abs(d.M42) < Tolerance && MathF.Abs(d.M43) < Tolerance; + } +} diff --git a/src/Illusion.Assets/Illusion.Assets.csproj b/src/Illusion.Assets/Illusion.Assets.csproj index fc60df6..093bb30 100644 --- a/src/Illusion.Assets/Illusion.Assets.csproj +++ b/src/Illusion.Assets/Illusion.Assets.csproj @@ -16,6 +16,8 @@ + + diff --git a/src/Illusion.Assets/Materials/ArchiveMaterials.cs b/src/Illusion.Assets/Materials/ArchiveMaterials.cs new file mode 100644 index 0000000..8212d74 --- /dev/null +++ b/src/Illusion.Assets/Materials/ArchiveMaterials.cs @@ -0,0 +1,328 @@ +using System.Globalization; +using System.Security.Cryptography; +using System.Text.Json.Nodes; +using Illusion.Formats.Archive; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Materials; +using Illusion.Formats.Materials.Versions; + +namespace Illusion.Assets.Materials; + +/// +/// The materials an archive's meshes are drawn with, each in full - and which of them the game does not have. +/// +/// A mesh names its materials by hash; the definitions are not in the archive but in the game's material +/// libraries (edit\materials\*.mtl). So an archive handed to someone else draws right only where their +/// libraries have every material it names: the ones that were added, or changed, have to travel with it. This +/// reads the archive FILE as it stands - what would be shipped - and the libraries from disk, and says for each +/// material where it comes from (), everything its definition holds, and whether the +/// archive itself carries its textures. +/// +/// +public static class ArchiveMaterials +{ + /// The version of the layout writes. + public const int Format = 1; + + /// A texture slot of a material. : the archive carries the texture + /// itself; when false it has to be in an archive the game has loaded anyway. + public sealed record Sampler(string Id, string Name, string Texture, ulong TextureHash, bool InArchive, byte TexType, byte UnkZero, + IReadOnlyList Set0, IReadOnlyList Set1, IReadOnlyList States); + + public sealed record Parameter(string Id, string Name, IReadOnlyList Values); + + /// A material in full. The fields without a name are the library's own, as it stores them + /// (, and exist in version 58 only). + /// and : how much of the archive is drawn with it. + public sealed record Material(string Name, ulong Hash, MaterialOrigin Origin, string Library, int LibraryVersion, uint Flags, ulong ShaderId, + uint ShaderHash, byte Unk0, byte Unk1, byte Unk2, byte Unk3, int Unk4, int Unk5, byte Unk6, float Unk7, IReadOnlyList Samplers, + IReadOnlyList Parameters, int Parts, int Triangles); + + /// A material the archive names and no library has: those parts draw with no material. + public sealed record Missing(ulong Hash, string? Name, int Parts, int Triangles); + + /// What an archive is drawn with. is false when there is no list of + /// this edition's own materials - every origin is then . + public sealed record Report(string Archive, long Size, string Sha256, bool OriginKnown, IReadOnlyList Materials, + IReadOnlyList Missing, IReadOnlyList Textures) + { + /// The materials that have to travel with the archive: added, or no longer the game's own. + public IEnumerable NotShipped => Materials.Where(m => m.Origin is MaterialOrigin.Added or MaterialOrigin.Changed); + } + + /// The material libraries of a game, read from disk, in the order a hash is looked up in. + public static IReadOnlyList LoadLibraries(string gameRoot) + { + ArgumentNullException.ThrowIfNull(gameRoot); + string dir = Path.Combine(gameRoot, "edit", "materials"); + if (!Directory.Exists(dir)) throw new DirectoryNotFoundException($"the game has no material libraries: {dir}"); + // the game's own three first, then any other library of the folder by name - as MafiaMaterials loads them + string[] canonical = ["default.mtl", "default50.mtl", "default60.mtl"]; + IEnumerable paths = canonical.Select(name => Path.Combine(dir, name)).Where(File.Exists) + .Concat(Directory.EnumerateFiles(dir, "*.mtl", SearchOption.TopDirectoryOnly) + .Where(p => Path.GetExtension(p).Equals(".mtl", StringComparison.OrdinalIgnoreCase) + && !canonical.Contains(Path.GetFileName(p), StringComparer.OrdinalIgnoreCase)) + .OrderBy(p => p, StringComparer.OrdinalIgnoreCase)); + var libraries = new List(); + foreach (string path in paths) + { + var library = new MaterialLibrary(MaterialVersion.V_57); + library.ReadMatFile(path); + libraries.Add(library); + } + if (libraries.Count == 0) throw new FileNotFoundException("the game has no material library", Path.Combine(dir, "default.mtl")); + return libraries; + } + + /// Reads an archive of the game the toolkit has open against that game's libraries. + public static Report Read(FileInfo archive) + { + if (!MafiaEnvironment.IsInitialized) throw new InvalidOperationException("the game folder is not set"); + return Read(archive, LoadLibraries(MafiaEnvironment.GameRoot)); + } + + /// There is no such archive. + public static Report Read(FileInfo archive, IReadOnlyList libraries) + { + ArgumentNullException.ThrowIfNull(archive); + ArgumentNullException.ThrowIfNull(libraries); + if (!archive.Exists) throw new FileNotFoundException($"no such archive: {archive.FullName}", archive.FullName); + + // ---- what the archive names, and what it carries + var used = new Dictionary(); + var textures = new HashSet(StringComparer.OrdinalIgnoreCase); + string scratch = Path.Combine(Path.GetTempPath(), "illusion_materials_" + Guid.NewGuid().ToString("N")); + try + { + SdsMemoryRequirements.Extract(SdsArchive.Open(archive.FullName), scratch); + SdsManifest manifest = SdsManifest.Load(scratch); + foreach (string file in manifest.GetFiles("FrameResource")) + { + foreach (FrameMaterial block in new FrameResource(file).FrameMaterials.Values) + { + foreach (MaterialStruct part in (block.Materials ?? []).SelectMany(level => level ?? [])) + { + if (part.MaterialHash == 0) continue; + used.TryGetValue(part.MaterialHash, out (string? Name, int Parts, int Triangles) seen); + used[part.MaterialHash] = (seen.Name ?? Text(part.MaterialName), seen.Parts + 1, seen.Triangles + Math.Max(0, part.NumFaces)); + } + } + } + // a texture is one or two resources: the picture, and for the large ones its full size under MIP_ + foreach (string file in manifest.GetFiles("Texture")) textures.Add(Path.GetFileName(file)); + foreach (string file in manifest.GetFiles("Mipmap")) + { + string name = Path.GetFileName(file); + textures.Add(name.StartsWith("MIP_", StringComparison.OrdinalIgnoreCase) ? name[4..] : name); + } + } + finally + { + try + { + if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // the scratch copy is left behind in the temp folder + } + } + + // ---- each of them, out of the first library that has it + bool known = libraries.Any(l => ShippedMaterials.Covers(l.Version)); + var materials = new List(); + var missing = new List(); + foreach ((ulong hash, (string? name, int parts, int triangles)) in used) + { + MaterialLibrary? library = libraries.FirstOrDefault(l => l.Materials.ContainsKey(hash)); + if (library == null) + { + missing.Add(new Missing(hash, name, parts, triangles)); + continue; + } + materials.Add(Describe(library.Materials[hash], library, textures, parts, triangles)); + } + materials.Sort((a, b) => a.Origin != b.Origin ? Rank(a.Origin).CompareTo(Rank(b.Origin)) : string.Compare(a.Name, b.Name, StringComparison.OrdinalIgnoreCase)); + missing.Sort((a, b) => a.Hash.CompareTo(b.Hash)); + using FileStream stream = archive.OpenRead(); + return new Report(archive.Name, archive.Length, Convert.ToHexStringLower(SHA256.HashData(stream)), known, materials, missing, + [.. textures.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)]); + } + + // the ones that have to travel first + private static int Rank(MaterialOrigin origin) => origin switch + { + MaterialOrigin.Added => 0, + MaterialOrigin.Changed => 1, + MaterialOrigin.Unknown => 2, + _ => 3, + }; + + private static string? Text(string? name) => string.IsNullOrWhiteSpace(name) || name == "null" ? null : name; + + private static Material Describe(IMaterial material, MaterialLibrary library, HashSet textures, int parts, int triangles) + { + Sampler Slot(IMaterialSampler s, string? texture, byte texType, byte unkZero, int[]? set0, int[]? set1) => + new(s.ID ?? "", MaterialParameterNames.GetName(s.ID ?? ""), texture ?? "", string.IsNullOrEmpty(texture) ? 0 : s.GetFileHash(), + !string.IsNullOrEmpty(texture) && textures.Contains(texture), texType, unkZero, [.. set0 ?? []], [.. set1 ?? []], [.. s.SamplerStates ?? []]); + + byte unk0, unk1, unk2 = 0, unk3, unk6 = 0; + int unk4, unk5; + float unk7 = 0f; + List samplers; + switch (material) + { + case Material_v57 m: + (unk0, unk1, unk3, unk4, unk5) = (m.Unk0, m.Unk1, m.Unk3, m.Unk4, m.Unk5); + samplers = [.. m.Samplers.Select(s => Slot(s, s.TextureName?.String, s.TexType, s.UnkZero, s.UnkSet0, s.UnkSet1))]; + break; + case Material_v58 m: + (unk0, unk1, unk2, unk3, unk4, unk5, unk6, unk7) = (m.Unk0, m.Unk1, m.Unk2, m.Unk3, m.Unk4, m.Unk5, m.Unk6, m.Unk7); + samplers = [.. m.Samplers.Select(s => Slot(s, s.TextureName?.String, s.TexType, s.UnkZero, s.UnkSet0, s.UnkSet1))]; + break; + default: + throw new NotSupportedException($"a material of the kind {material.GetType().Name} is not one this reads"); + } + return new Material(material.GetMaterialName() ?? "", material.GetMaterialHash(), ShippedMaterials.OriginOf(material), + Path.GetFileName(library.Name), (int)library.Version, (uint)material.Flags, material.ShaderID, material.ShaderHash, + unk0, unk1, unk2, unk3, unk4, unk5, unk6, unk7, samplers, + [.. material.Parameters.Select(p => new Parameter(p.ID ?? "", MaterialParameterNames.GetName(p.ID ?? ""), [.. p.Paramaters ?? []]))], + parts, triangles); + } + + /// + /// The report as a document for whoever takes the archive: by default only the materials that have to travel + /// with it (added or changed) and the ones no library has; with every material. + /// 64-bit hashes are written as hex text - a JSON number that long does not survive a JavaScript reader. + /// + public static JsonObject ToJson(Report report, bool all) + { + ArgumentNullException.ThrowIfNull(report); + var materials = new JsonArray(); + foreach (Material m in report.Materials) + { + // where the origin is not known nothing can be left out as "the game's own" + if (!all && report.OriginKnown && m.Origin == MaterialOrigin.Shipped) continue; + var samplers = new JsonArray(); + foreach (Sampler s in m.Samplers) + { + samplers.Add(new JsonObject + { + ["id"] = s.Id, + ["name"] = s.Name, + ["texture"] = s.Texture, + ["textureHash"] = Hex(s.TextureHash), + ["inArchive"] = s.InArchive, + ["texType"] = s.TexType, + ["unkZero"] = s.UnkZero, + ["set0"] = new JsonArray([.. s.Set0.Select(v => (JsonNode)v)]), + ["set1"] = new JsonArray([.. s.Set1.Select(v => (JsonNode)v)]), + ["states"] = new JsonArray([.. s.States.Select(v => (JsonNode)(int)v)]), + }); + } + var parameters = new JsonArray(); + foreach (Parameter p in m.Parameters) + { + parameters.Add(new JsonObject { ["id"] = p.Id, ["name"] = p.Name, ["values"] = new JsonArray([.. p.Values.Select(v => (JsonNode)v)]) }); + } + var fields = new JsonObject { ["unk0"] = m.Unk0, ["unk1"] = m.Unk1, ["unk3"] = m.Unk3, ["unk4"] = m.Unk4, ["unk5"] = m.Unk5 }; + if (m.LibraryVersion >= 58) + { + fields["unk2"] = m.Unk2; + fields["unk6"] = m.Unk6; + fields["unk7"] = m.Unk7; + } + materials.Add(new JsonObject + { + ["name"] = m.Name, + ["hash"] = Hex(m.Hash), + ["origin"] = m.Origin.ToString().ToLowerInvariant(), + ["library"] = m.Library, + ["libraryVersion"] = m.LibraryVersion, + ["flags"] = m.Flags, + ["flagsHex"] = "0x" + m.Flags.ToString("x8", CultureInfo.InvariantCulture), + ["shaderId"] = Hex(m.ShaderId), + ["shaderHash"] = "0x" + m.ShaderHash.ToString("x8", CultureInfo.InvariantCulture), + ["fields"] = fields, + ["samplers"] = samplers, + ["parameters"] = parameters, + ["parts"] = m.Parts, + ["triangles"] = m.Triangles, + }); + } + var missing = new JsonArray(); + foreach (Missing m in report.Missing) + { + missing.Add(new JsonObject { ["hash"] = Hex(m.Hash), ["name"] = m.Name, ["parts"] = m.Parts, ["triangles"] = m.Triangles }); + } + return new JsonObject + { + ["format"] = Format, + ["about"] = "Materials of an archive, written by Illusion Toolkit. A mesh names its materials by hash; the definitions live in the " + + "game's material libraries, not in the archive. 'origin' says where each stands against the game as it ships: 'added' " + + "(the game has none of that name), 'changed' (the game's own, defined differently here), 'shipped', or 'unknown' (no list " + + "for this edition). Added and changed materials must be registered for the archive to draw as it was made. A sampler " + + "with 'inArchive' true has its texture inside the archive. 'missing' are hashes the archive names and no library has.", + ["archive"] = report.Archive, + ["size"] = report.Size, + ["sha256"] = report.Sha256, + ["originKnown"] = report.OriginKnown, + ["onlyNotShipped"] = !all && report.OriginKnown, + ["materials"] = materials, + ["missing"] = missing, + ["texturesInArchive"] = new JsonArray([.. report.Textures.Select(t => (JsonNode)t)]), + }; + } + + /// + /// Writes the materials says have to travel with the archive — added, or no longer + /// the game's own — as a material library of their own: a file a host loads beside the game's libraries, so + /// that the game's own are never touched (Mafia II Online takes it as stream/materials/<name>.mtl). + /// Returns how many materials it holds; an archive that needs none writes nothing and returns 0. + /// + /// The libraries the report was read against. + /// The materials come from libraries of different versions, which one + /// file cannot hold. + public static int SaveLibrary(Report report, IReadOnlyList libraries, string path) + { + ArgumentNullException.ThrowIfNull(report); + ArgumentNullException.ThrowIfNull(libraries); + ArgumentNullException.ThrowIfNull(path); + var travelling = new Dictionary(); + MaterialLibrary? home = null; + foreach (Material material in report.NotShipped) + { + // out of the first library that has it, as the report took it + if (libraries.FirstOrDefault(l => l.Materials.ContainsKey(material.Hash)) is not { } from) continue; + if (home != null && home.Version != from.Version) + { + throw new NotSupportedException($"'{material.Name}' is a version {(int)from.Version} material and " + + $"'{travelling.Values.First().GetMaterialName()}' a version {(int)home.Version} one - one library cannot hold both"); + } + home ??= from; + travelling[material.Hash] = from.Materials[material.Hash]; + } + if (home == null) return 0; + if (Path.GetDirectoryName(Path.GetFullPath(path)) is { Length: > 0 } folder) Directory.CreateDirectory(folder); + AtomicFile.WriteAllBytes(path, new MaterialLibrary(home.Version) { Materials = travelling }.ToBytes()); + return travelling.Count; + } + + /// Writes a document made by to a file, whole or not at all. + public static void Save(JsonObject document, string path) + { + ArgumentNullException.ThrowIfNull(document); + ArgumentNullException.ThrowIfNull(path); + if (Path.GetDirectoryName(Path.GetFullPath(path)) is { Length: > 0 } folder) Directory.CreateDirectory(folder); + AtomicFile.WriteAllBytes(path, new System.Text.UTF8Encoding(false).GetBytes(document.ToJsonString(Indented) + "\n")); + } + + private static readonly System.Text.Json.JsonSerializerOptions Indented = new() + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + private static string Hex(ulong hash) => "0x" + hash.ToString("x16", CultureInfo.InvariantCulture); +} diff --git a/src/Illusion.Assets/Materials/ShippedMaterials.bin b/src/Illusion.Assets/Materials/ShippedMaterials.bin new file mode 100644 index 0000000..35308bb Binary files /dev/null and b/src/Illusion.Assets/Materials/ShippedMaterials.bin differ diff --git a/src/Illusion.Assets/Materials/ShippedMaterials.cs b/src/Illusion.Assets/Materials/ShippedMaterials.cs new file mode 100644 index 0000000..5474cd9 --- /dev/null +++ b/src/Illusion.Assets/Materials/ShippedMaterials.cs @@ -0,0 +1,161 @@ +using System.Buffers.Binary; +using System.Text; +using Illusion.Formats.Materials; +using Illusion.Formats.Materials.Versions; + +namespace Illusion.Assets.Materials; + +/// Where a material of the library stands against the game as it ships. +public enum MaterialOrigin +{ + /// It came with the game and is as the game has it. + Shipped, + /// It came with the game, but its definition is no longer the game's own. + Changed, + /// The game does not have it: it was added. + Added, + /// Not known: there is no list of this edition's own materials (see ). + Unknown, +} + +/// +/// The materials Mafia II ships with, each with a digest of its definition - so that a material can be told to +/// be the game's own, the game's own changed, or added. +/// +/// The list is an embedded file made once from the three libraries of an untouched install +/// (default.mtl, default50.mtl, default60.mtl - library version 57) with . +/// The Definitive Edition's libraries (version 58) are not in it: for those the origin is not known. +/// +/// +public static class ShippedMaterials +{ + private const uint Magic = 0x544D5349; // "ISMT" + private const int Layout = 1; + private const string ResourceName = "ShippedMaterials.bin"; + + private static readonly Lazy> Known = new(Load); + + /// How many materials the list holds; zero when the list is not there. + public static int Count => Known.Value.Count; + + /// Whether the list speaks for libraries of this version. + public static bool Covers(MaterialVersion version) => version == MaterialVersion.V_57 && Known.Value.Count > 0; + + public static MaterialOrigin OriginOf(IMaterial material) + { + ArgumentNullException.ThrowIfNull(material); + if (!Covers(material.GetMTLVersion())) return MaterialOrigin.Unknown; + if (!Known.Value.TryGetValue(material.GetMaterialHash(), out ulong digest)) return MaterialOrigin.Added; + return digest == Digest(material) ? MaterialOrigin.Shipped : MaterialOrigin.Changed; + } + + /// + /// A digest of everything a material's definition holds: flags, shader, the fields without a name, every + /// sampler with its texture and states, every parameter with its values. Two materials digest alike only + /// when a game given one in place of the other would draw the same. + /// + public static ulong Digest(IMaterial material) + { + ArgumentNullException.ThrowIfNull(material); + using var bytes = new MemoryStream(); + using (var w = new BinaryWriter(bytes, Encoding.UTF8, leaveOpen: true)) + { + w.Write((int)material.GetMTLVersion()); + w.Write((uint)material.Flags); + w.Write(material.ShaderID); + w.Write(material.ShaderHash); + switch (material) + { + case Material_v57 m: + w.Write(m.Unk0); w.Write(m.Unk1); w.Write(m.Unk3); w.Write(m.Unk4); w.Write(m.Unk5); + w.Write(m.Samplers.Count); + foreach (MaterialSampler_v57 s in m.Samplers) Write(w, s, s.TextureName.String, s.TexType, s.UnkZero, s.UnkSet0, s.UnkSet1); + break; + case Material_v58 m: + w.Write(m.Unk0); w.Write(m.Unk1); w.Write(m.Unk2); w.Write(m.Unk3); w.Write(m.Unk4); w.Write(m.Unk5); w.Write(m.Unk6); w.Write(m.Unk7); + w.Write(m.Samplers.Count); + foreach (MaterialSampler_v58 s in m.Samplers) Write(w, s, s.TextureName.String, s.TexType, s.UnkZero, s.UnkSet0, s.UnkSet1); + break; + default: + throw new NotSupportedException($"a material of the kind {material.GetType().Name} cannot be digested"); + } + w.Write(material.Parameters.Count); + foreach (MaterialParameter p in material.Parameters) + { + w.Write(p.ID ?? ""); + float[] values = p.Paramaters ?? []; + w.Write(values.Length); + foreach (float v in values) w.Write(BitConverter.SingleToInt32Bits(v)); + } + } + // FNV-1a, 64 bit + ulong hash = 14695981039346656037UL; + foreach (byte b in bytes.GetBuffer().AsSpan(0, (int)bytes.Length)) + { + hash ^= b; + hash *= 1099511628211UL; + } + return hash; + } + + private static void Write(BinaryWriter w, IMaterialSampler sampler, string? texture, byte texType, byte unkZero, int[]? set0, int[]? set1) + { + w.Write(sampler.ID ?? ""); + w.Write(texture ?? ""); + w.Write(texType); + w.Write(unkZero); + foreach (int[]? set in new[] { set0, set1 }) + { + w.Write(set?.Length ?? 0); + foreach (int v in set ?? []) w.Write(v); + } + byte[] states = sampler.SamplerStates ?? []; + w.Write(states.Length); + w.Write(states); + } + + /// + /// The list's file, from the libraries of an untouched install: every material once (the first library to + /// name a hash wins, as the game's own lookup has it), sorted by hash. + /// + public static byte[] Build(IEnumerable materials) + { + ArgumentNullException.ThrowIfNull(materials); + var digests = new SortedDictionary(); + foreach (IMaterial material in materials) digests.TryAdd(material.GetMaterialHash(), Digest(material)); + byte[] file = new byte[12 + (digests.Count * 16)]; + BinaryPrimitives.WriteUInt32LittleEndian(file, Magic); + BinaryPrimitives.WriteInt32LittleEndian(file.AsSpan(4), Layout); + BinaryPrimitives.WriteInt32LittleEndian(file.AsSpan(8), digests.Count); + int at = 12; + foreach ((ulong hash, ulong digest) in digests) + { + BinaryPrimitives.WriteUInt64LittleEndian(file.AsSpan(at), hash); + BinaryPrimitives.WriteUInt64LittleEndian(file.AsSpan(at + 8), digest); + at += 16; + } + return file; + } + + // A list that is missing or does not read is no list: every origin is then Unknown, which says so - where + // a wrong list would call the game's own materials added. + private static Dictionary Load() + { + var known = new Dictionary(); + System.Reflection.Assembly assembly = typeof(ShippedMaterials).Assembly; + string? resource = assembly.GetManifestResourceNames().FirstOrDefault(n => n.EndsWith(ResourceName, StringComparison.Ordinal)); + if (resource == null) return known; + using Stream? stream = assembly.GetManifestResourceStream(resource); + if (stream == null) return known; + byte[] file = new byte[stream.Length]; + stream.ReadExactly(file); + if (file.Length < 12 || BinaryPrimitives.ReadUInt32LittleEndian(file) != Magic || BinaryPrimitives.ReadInt32LittleEndian(file.AsSpan(4)) != Layout) return known; + int count = BinaryPrimitives.ReadInt32LittleEndian(file.AsSpan(8)); + if (count < 0 || file.Length != 12 + ((long)count * 16)) return known; + for (int i = 0, at = 12; i < count; i++, at += 16) + { + known[BinaryPrimitives.ReadUInt64LittleEndian(file.AsSpan(at))] = BinaryPrimitives.ReadUInt64LittleEndian(file.AsSpan(at + 8)); + } + return known; + } +} diff --git a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs index a5f63c8..2f04b8b 100644 --- a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs +++ b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs @@ -271,15 +271,41 @@ private static void AddSector(FrameObjectSector s, GroupCollector c) c.AddType("Sector", HashNameDesc("Sector.SectorName", "Sector name", () => s.SectorName, name => s.SectorName.Set(name))); c.AddType("Sector", Vec3Desc("Sector.BoundsMin", "Bounds min", - () => s.Bounds.Min, v => { var b = s.Bounds; b.Min = v; s.Bounds = b; })); + () => s.Bounds.Min, v => { var b = s.Bounds; Vector3 was = b.Min; b.Min = v; s.Bounds = b; SectorPlanesFollow(s, v - was, Vector3.Zero); })); c.AddType("Sector", Vec3Desc("Sector.BoundsMax", "Bounds max", - () => s.Bounds.Max, v => { var b = s.Bounds; b.Max = v; s.Bounds = b; })); + () => s.Bounds.Max, v => { var b = s.Bounds; Vector3 was = b.Max; b.Max = v; s.Bounds = b; SectorPlanesFollow(s, Vector3.Zero, v - was); })); AddPlanes("Sector.Planes", s.Planes, c, "Sector"); c.AddTypeUnknown(IntDesc("Sector.Unk08", "Unk08", () => s.Unk08, v => s.Unk08 = v)); c.AddTypeUnknown(Vec3Desc("Sector.Unk13", "Unk13", () => s.Unk13, v => s.Unk13 = v)); c.AddTypeUnknown(Vec3Desc("Sector.Unk14", "Unk14", () => s.Unk14, v => s.Unk14 = v)); } + // A sector says its volume twice: the box, and the planes that bound it (inside is n.p + d >= 0). The game + // asks the planes - a box moved on its own left the room's light where it was. For a sector that IS a box + // (every plane square to an axis) each plane goes with the face of the box it stands at, keeping the small + // margin the game's own sectors have between the two. A sector cut to another shape keeps its planes: there + // the box is only their envelope. + private static void SectorPlanesFollow(FrameObjectSector s, Vector3 minMoved, Vector3 maxMoved) + { + Vector4[] planes = s.Planes; + if (planes is not { Length: > 0 }) return; + var moved = new Vector4[planes.Length]; + for (int i = 0; i < planes.Length; i++) + { + Vector4 plane = planes[i]; + var n = new Vector3(plane.X, plane.Y, plane.Z); + int axis = MathF.Abs(n.X) > 0.999f ? 0 : MathF.Abs(n.Y) > 0.999f ? 1 : MathF.Abs(n.Z) > 0.999f ? 2 : -1; + if (axis < 0) return; // not a box: nothing is changed + float along = axis == 0 ? n.X : axis == 1 ? n.Y : n.Z; + // a plane looking down its axis bounds the box from above (d = max + margin), one looking up from below (d = margin - min) + float shift = along < 0f + ? (axis == 0 ? maxMoved.X : axis == 1 ? maxMoved.Y : maxMoved.Z) + : -(axis == 0 ? minMoved.X : axis == 1 ? minMoved.Y : minMoved.Z); + moved[i] = new Vector4(plane.X, plane.Y, plane.Z, plane.W + shift); + } + s.Planes = moved; + } + private static void AddTarget(FrameObjectTarget t, GroupCollector c) { c.AddTypeUnknown(IntDesc("Target.Unk01", "Unk01", () => t.Unk01, v => t.Unk01 = v)); diff --git a/src/Illusion.Assets/Sds/SdsMeshLoader.cs b/src/Illusion.Assets/Sds/SdsMeshLoader.cs index 1ecf3c6..594866f 100644 --- a/src/Illusion.Assets/Sds/SdsMeshLoader.cs +++ b/src/Illusion.Assets/Sds/SdsMeshLoader.cs @@ -142,7 +142,7 @@ public static (List Roots, List Meshes, ISceneDocument? Collisions.CarPhysicsVolumes.AlignStubsToPrefab(folder, fr); var document = new SceneDocumentAdapter(fr, sdsFile, placements); - var roots = BuildRoots(fr, document, others, meshes, null, lod); + var roots = BuildRoots(fr, document, others, meshes, null, lod, IsCityArchive(sdsFile)); return (roots, meshes, document); } @@ -160,7 +160,8 @@ public static (List Roots, List Meshes, ISceneDocument? var document = new SceneDocumentAdapter(fr, crashSds, actors); CrashPlacements? placements = LoadPlacements(crashSds, extracted, fr); - var roots = BuildRoots(fr, document, Array.Empty(), meshes, placements?.BuildClouds(), lod); + var roots = BuildRoots(fr, document, Array.Empty(), meshes, placements?.BuildClouds(), lod, + IsCityArchive(crashSds)); return (roots, meshes, document, placements); } @@ -206,11 +207,30 @@ private static (TranslokatorLoader? Table, FileInfo? Archive) LoadTwinTable(File } } + /// + /// Whether an archive is one of the city's own — a district, city_univers, the crash layer: the + /// archives of the folders sds\city, sds\city_univers and sds\city_crash. Only those + /// have proxy and snow scenes to tell apart (see ). + /// + /// The name-table flags that mark a proxy in a district mean something else everywhere else. An interior + /// under sds\shops stands its rooms under holders the game moves to the shop's place + /// (GUNSHOP_translocator_00, AREA_POvrsek_meeting), and those holders carry the very bits a + /// district's far backdrops do — 0x1C01 on El Greco's, 0x1501 on the gun shop's, 0x3 ("snow") on the + /// planetarium dome's. Read as in a district, 38 of the shipped interiors opened with the scene of their + /// rooms classed as proxy or snow and so hidden by the map's filters: the walls, floor and ceiling of + /// every one of them (904 of the 1067 meshes of Derek's office, all 157 of the second Crazy Horse), with + /// only the loose props left standing in the air. + /// + /// + internal static bool IsCityArchive(FileInfo sds) => + sds.Directory?.Name.StartsWith("city", StringComparison.OrdinalIgnoreCase) == true; + // Builds tree roots from FrameResource. instanceMap (if provided) marks prototype meshes as instanced. + // cityArchive: whether the scenes are sorted into proxy / snow / normal at all (see IsCityArchive). private static List BuildRoots(FrameResource fr, SceneDocumentAdapter document, IReadOnlyCollection others, List meshes, IReadOnlyDictionary? instanceMap, - int lod) + int lod, bool cityArchive) { var roots = new List(); @@ -241,7 +261,7 @@ private static List BuildRoots(FrameResource fr, SceneDocumentAdap if (claimed.Add(obj)) sn.Children.Add(BuildNode(obj, document, childrenOf, meshes, instanceMap, claimed, lod)); if (sn.Children.Count > 0) { - sn.Category = CategorizeScene(sn, others); + if (cityArchive) sn.Category = CategorizeScene(sn, others); roots.Add(sn); } } @@ -384,7 +404,8 @@ private static SdsFrameNode BuildNode(FrameObjectBase obj, SceneDocumentAdapter } // Scene category for streaming filters, from the majority season class of its mesh leaves (FrameNameTable - // flags, cascaded to unflagged children — see ClassifyNode): + // flags, cascaded to unflagged children — see ClassifyNode). Asked of the city's archives only: anywhere + // else every scene is Normal (see IsCityArchive). // Proxy — most meshes are proxies (cityNN / neighbor-district / LOD backdrops); // Snow — most meshes are winter geometry (flag_1|flag_2); // otherwise Normal. diff --git a/src/Illusion.Assets/World/ShippedInteriors.txt b/src/Illusion.Assets/World/ShippedInteriors.txt new file mode 100644 index 0000000..8da30fa --- /dev/null +++ b/src/Illusion.Assets/World/ShippedInteriors.txt @@ -0,0 +1,69 @@ +# The interiors of cityshops.bin as Mafia II ships them (the main game's table and Joe's Adventures'). +# An interior not named here was added; only such a one is taken out by ShopPlaces.Delete. +Fast +Nadzemka +Respray +Odevy +05Byt +Gunshop +Harry +Lonestar +VitoA2 +Irish +Giuseppe +Fredy +Franhome +Show +Vinyl +Gvexterier +04Roof_MArcade +Gas +Falcone_satna +OdevyL +Illia +Vitovila +VitoA1 +Psycho +15Planetdom +Bruno +15Planetex +04MArcade +shkanal +cathouse +argaraz +triads0 +erik_chodba +construction_prizemi +construction_strecha +derek +joesflat_chodba +service +appliance +betting +bruski +deli +maria_agnelo +mona +real +sgoffice +subway +elgreco +crazy_horse +mtrain +garage +03price +crazy_horse2 +vitohouseb12 +Joesflat_chodba_b +l_garage +15mansion +vitoa3shop +wgarage +dlc_printery_storage +dlc_printery_roof +dlc_ring +dlc_cathouse_chodba +dlc_lokace_supermarket +dlc_hotel_entrance +dlc_port +dlc_kyprehrada diff --git a/src/Illusion.Assets/World/ShopPlaces.cs b/src/Illusion.Assets/World/ShopPlaces.cs new file mode 100644 index 0000000..2f06d51 --- /dev/null +++ b/src/Illusion.Assets/World/ShopPlaces.cs @@ -0,0 +1,598 @@ +using System.Globalization; +using System.Numerics; +using Illusion.Assets.Frames; +using Illusion.Assets.Sds; +using Illusion.Formats.Archive; +using Illusion.Formats.City; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Hashing; + +namespace Illusion.Assets.World; + +/// +/// The places the game's interiors stand at, and one more of them. +/// +/// An interior - a shop, a bar, a flat - is an archive under shops\ that the game loads when the player +/// comes near and stands where a MARKER says. One archive can stand at several places (the gun shop at eleven): +/// the archive holds a marker frame per place, the scene of city_univers a pair of volumes per place - +/// inside the first the interior is loaded, outside the wider second it is let go - and +/// cityshops.bin names them all (). +/// +/// +/// So a new place is three things written together: a marker in the interior's archive, two volumes in +/// city_univers, and two rows of the table. Seen in the game (a multiplayer client, a twelfth gun shop +/// where the game has none): the interior loads there and its own shop menu opens. +/// +/// +public sealed class ShopPlaces +{ + /// One place of an interior. and (degrees about the + /// vertical) are the marker's, known only when the interior's archive was read; the zones are the pair of + /// volumes that holds the marker; is false for a place the game ships with. + public sealed record PlaceInfo(string Shop, string Archive, string Marker, Vector3? At, float? Turn, float MapX, float MapY, + string? LoadZone, string? UnloadZone, bool Added); + + /// An interior of the table. is its file under shops\ without the + /// extension, or null when there is no such file. + public sealed record ShopInfo(string Name, string? Archive, string ActorFile, int Entities, IReadOnlyList Places); + + private readonly Func _ensureExtracted; + private readonly string _tableFile; + private readonly CityShopsTable _table; + private readonly FrameResource _cityScene; + private FileInfo? _shopArchive; + private FrameResource? _shopScene; + private string? _copyFrom; // the working copy a new interior's own is made from, on Save + private bool _tableOnly; // an interior was taken out: only city_univers changed + private FileInfo? _copySource; // the archive that working copy belongs to + + /// The archive the volumes and the table are in. + public FileInfo CityArchive { get; } + + /// The interior's archive a place was added to or taken out of, once one was. + public FileInfo? ShopArchive => _shopArchive; + + /// True when is an interior made here that has no archive file yet: + /// after its working copy is there to be packed for the first time. + public bool ShopArchiveIsNew => _copyFrom != null; + + public CityShopsTable Table => _table; + + /// The scenes as they stand in memory - for the probe that checks what a change did to them. + internal FrameResource CityScene => _cityScene; + + internal FrameResource? ShopScene => _shopScene; + + private ShopPlaces(Func ensureExtracted, FileInfo city, string tableFile, CityShopsTable table, FrameResource cityScene) + { + _ensureExtracted = ensureExtracted; + CityArchive = city; + _tableFile = tableFile; + _table = table; + _cityScene = cityScene; + } + + /// Reads the table and the scene of the base city_univers.sds from its working copy. + /// The working copy has no table or no scene. + public static ShopPlaces Open(Func ensureExtracted) + { + ArgumentNullException.ThrowIfNull(ensureExtracted); + var city = new FileInfo(MafiaEnvironment.CityUniversSds); + string extracted = ensureExtracted(city); + string tableFile = Directory.EnumerateFiles(extracted, "cityshops.bin", SearchOption.AllDirectories).FirstOrDefault() + ?? throw new FileNotFoundException("the working copy of city_univers has no cityshops.bin", Path.Combine(extracted, "cityshops.bin")); + FrameResource scene = SdsMeshLoader.OpenScene(extracted).FrameResource + ?? throw new FileNotFoundException("the working copy of city_univers has no scene", extracted); + return new ShopPlaces(ensureExtracted, city, tableFile, CityShopsTable.Parse(File.ReadAllBytes(tableFile)), scene); + } + + /// + /// The interiors of the table. For the one named by its archive is read too, + /// so that its places come with where their markers stand and which volumes hold them; the others list + /// their places as the table has them. + /// + public IReadOnlyList Shops(string? withMarkers = null) + { + var list = new List(_table.Shops.Count); + foreach (CityShopsTable.Shop shop in _table.Shops) + { + string archive = ArchiveOf(shop); + bool exists = ArchiveFile(archive).Exists; + FrameResource? scene = exists && withMarkers != null && Is(shop, withMarkers) ? SceneOf(archive) : null; + var places = new List(shop.Places.Count); + foreach (CityShopsTable.Place place in shop.Places) places.Add(Describe(shop, archive, place, scene)); + list.Add(new ShopInfo(shop.Name, exists ? archive : null, shop.ActorFile, shop.Entities.Count, places)); + } + return list; + } + + /// + /// Adds a place for an interior, IN MEMORY: a marker in its archive at - the point the + /// interior's own origin goes to - turned by about the vertical, a pair of + /// box volumes round it in city_univers, and the rows of the table. writes it. + /// + /// Half the side of the box inside which the interior is loaded, metres. + /// Half the side of the wider box outside which it is let go. + /// Half the height of both boxes. + /// Null on success, otherwise why not; nothing is changed on a refusal. + public string? Add(string shop, Vector3 at, float turnDegrees, float loadHalf, float unloadHalf, float halfHeight, out PlaceInfo? made) + { + made = null; + if (Sizes(at, turnDegrees, loadHalf, unloadHalf, halfHeight) is { } bad) return bad; + + CityShopsTable.Shop? row = _table.Shops.FirstOrDefault(s => Is(s, shop)); + if (row == null) return $"the table has no interior named '{shop}'; it has: {string.Join(", ", _table.Shops.Select(s => s.Name))}"; + if (row.Places.Count == 0) return $"{row.Name} has no place yet to make the new one like"; + return AddPlace(row, row.Places[0], at, turnDegrees, loadHalf, unloadHalf, halfHeight, out made); + } + + private static string? Sizes(Vector3 at, float turnDegrees, float loadHalf, float unloadHalf, float halfHeight) + { + foreach (float v in new[] { at.X, at.Y, at.Z, turnDegrees, loadHalf, unloadHalf, halfHeight }) + { + if (!float.IsFinite(v)) return "the place, the turn and the sizes must be finite numbers"; + } + if (loadHalf < 5f || halfHeight < 3f) return "the load box must be at least 10 m wide and 6 m high"; + return unloadHalf <= loadHalf + ? "the unload box must be wider than the load box - it is what the player has to leave for the interior to be let go" + : null; + } + + // The place itself. `first` is the place whose map icon, text and per-entity marks the new one takes. + private string? AddPlace(CityShopsTable.Shop row, CityShopsTable.Place first, Vector3 at, float turnDegrees, float loadHalf, float unloadHalf, + float halfHeight, out PlaceInfo? made) + { + made = null; + string archive = ArchiveOf(row); + if (Hold(archive) is { } notHeld) return notHeld; + FrameResource scene = _shopScene!; + + // the marker to copy: one of the interior's own places - an empty frame of the scene - or, when the + // interior has only the marker that carries it, that one (a copy takes none of what hangs on it) + FrameObjectBase? holder = Find(scene, row.Holder); + List markers = [.. row.Places.Select(p => Find(scene, p.Marker)).OfType()]; + FrameObjectBase? pattern = markers.FirstOrDefault(m => m.Parent == null && m.Children.Count == 0) ?? (holder is { Parent: null } ? holder : null); + if (pattern is not FrameObjectDummy like) return $"the archive of {row.Name} has no marker of its own to copy (a frame of the scene named as the table names its places)"; + string marker = NextMarker(scene, row, like.Name.String ?? row.Holder); + + // the pair of volumes to copy: this interior's own when they stand square to the map, else any pair that does + CityShopsTable.Area? likeRow = _table.Areas.Where(a => Same(a.Archive, archive)).FirstOrDefault(Square) + ?? _table.Areas.FirstOrDefault(Square); + if (likeRow == null) return "city_univers has no pair of volumes standing square to the map to copy"; + var likeLoad = (FrameObjectArea)Find(_cityScene, likeRow.LoadZone)!; + var likeUnload = (FrameObjectArea)Find(_cityScene, likeRow.UnloadZone)!; + // named after this interior as its own volumes are, where it has some; else after the interior itself + (string loadName, string unloadName) = NextZones(Same(likeRow.Archive, archive) ? likeRow.LoadZone : "", row.Name); + foreach (FrameObjectArea source in new[] { likeLoad, likeUnload }) + { + Matrix4x4 parent = (source.Parent ?? source.Root)?.WorldTransform ?? Matrix4x4.Identity; + parent.Translation = Vector3.Zero; + if (!Matrix4x4.Invert(Whole(parent), out _)) return $"{source.Name} hangs on a frame whose transform cannot be inverted"; + } + + // ---- everything can be done: the marker + var clone = new FrameObjectDummy(like) { Name = new HashName(marker) }; + scene.FrameObjects.Add(clone.RefID, clone); + FrameDuplicator.LinkParents(clone, FrameDuplicator.ResolveRef(scene, like, FrameEntryRefTypes.Parent1), FrameDuplicator.ResolveRef(scene, like, FrameEntryRefTypes.Parent2)); + // it stands as the marker carrying the interior stands, turned about the vertical, at the new place + Matrix4x4 stand = Whole((holder ?? like).WorldTransform); + stand.Translation = Vector3.Zero; + Matrix4x4 turned = stand * Matrix4x4.CreateRotationZ(turnDegrees * MathF.PI / 180f); + Matrix4x4 local = like.LocalTransform; // the fourth column as this scene stores it + local.M11 = turned.M11; local.M12 = turned.M12; local.M13 = turned.M13; + local.M21 = turned.M21; local.M22 = turned.M22; local.M23 = turned.M23; + local.M31 = turned.M31; local.M32 = turned.M32; local.M33 = turned.M33; + local.Translation = at; + clone.LocalTransform = local; + + // ---- the two volumes + AddVolume(likeLoad, loadName, at, new Vector3(loadHalf, loadHalf, halfHeight)); + AddVolume(likeUnload, unloadName, at, new Vector3(unloadHalf, unloadHalf, halfHeight)); + + // ---- the rows + var place = new CityShopsTable.Place + { + Marker = marker, MapX = at.X, MapY = at.Y, Icon = first.Icon, TextId = first.TextId, Marks = [.. first.Marks], Pad = first.Pad, + }; + row.Places.Add(place); + _table.Areas.Add(new CityShopsTable.Area { LoadZone = loadName, UnloadZone = unloadName, Archive = archive, Pad = likeRow.Pad }); + made = new PlaceInfo(row.Name, archive, marker, at, turnDegrees, at.X, at.Y, loadName, unloadName, true); + return null; + } + + /// + /// Takes a place that was ADDED out again, IN MEMORY: its marker, its pair of volumes and its rows. + /// A place the game ships with is refused. writes it. + /// + /// Null on success, otherwise why not; nothing is changed on a refusal. + public string? Remove(string marker, out PlaceInfo? removed) + { + removed = null; + CityShopsTable.Shop? row = _table.Shops.FirstOrDefault(s => s.Places.Any(p => Same(p.Marker, marker))); + if (row == null) return $"no place of the table has the marker '{marker}'"; + CityShopsTable.Place place = row.Places.First(p => Same(p.Marker, marker)); + if (row.Places.Count == 1) return $"it is the only place of {row.Name}"; + string archive = ArchiveOf(row); + if (Hold(archive) is { } notHeld) return notHeld; + FrameResource scene = _shopScene!; + + if (Find(scene, place.Marker) is not { } frame) return $"the archive of {row.Name} has no frame named '{place.Marker}'"; + if (frame.Children.Count > 0 || scene.FrameObjects.Values.OfType().Any(o => ReferenceEquals(o.Parent, frame) || ReferenceEquals(o.Root, frame))) + return $"other frames hang on {place.Marker} - it carries the interior itself"; + PlaceInfo info = Describe(row, archive, place, scene); + if (info.LoadZone == null || info.UnloadZone == null) return $"no pair of volumes of the table holds {place.Marker} - it is not a place that was added here"; + if (!info.Added) return $"{place.Marker} came with the game ({info.LoadZone}) - only a place that was added is taken out"; + CityShopsTable.Area area = _table.Areas.First(a => Same(a.LoadZone, info.LoadZone) && Same(a.UnloadZone, info.UnloadZone)); + + Unlink(scene, frame); + foreach (string zone in new[] { area.LoadZone, area.UnloadZone }) + { + // a volume another row still names stays (two of the game's own load boxes share one unload box) + if (_table.Areas.Any(a => !ReferenceEquals(a, area) && (Same(a.LoadZone, zone) || Same(a.UnloadZone, zone)))) continue; + if (Find(_cityScene, zone) is { } volume) Unlink(_cityScene, volume); + } + _table.Areas.Remove(area); + row.Places.Remove(place); + removed = info; + return null; + } + + /// + /// Makes an interior OF ONE'S OWN, IN MEMORY: a copy of one of the table's interiors under a new name, with + /// its first place at . The copy is an archive of its own - shops\<name>.sds, + /// made from the working copy of - whose marker frames bear the new name, and a row + /// of its own in the table with the entities of the one it was copied from. What stands in it is then + /// changed like any archive. writes the working copies; the new archive is packed after. + /// + /// Null on success, otherwise why not; nothing is changed on a refusal. + public string? Create(string name, string like, Vector3 at, float turnDegrees, float loadHalf, float unloadHalf, float halfHeight, out PlaceInfo? made) + { + made = null; + if (Sizes(at, turnDegrees, loadHalf, unloadHalf, halfHeight) is { } bad) return bad; + if (string.IsNullOrWhiteSpace(name) || name.Length is < 3 or > 31 || !char.IsAsciiLetterLower(name[0]) + || name.Any(c => !(char.IsAsciiLetterLower(c) || char.IsAsciiDigit(c) || c == '_'))) + { + return "an interior's name is 3 to 31 lower-case letters, digits and '_', starting with a letter - it is its archive's name too"; + } + if (_shopArchive != null) return $"a place of {Path.GetFileNameWithoutExtension(_shopArchive.Name)} is being changed - save it first"; + if (_table.Shops.Any(s => Is(s, name)) || _table.Areas.Any(a => Same(a.Archive, name))) return $"the table already has an interior named '{name}'"; + FileInfo archive = ArchiveFile(name); + if (archive.Exists) return $"there is already a shops\\{name}.sds - pick another name"; + string folder = MafiaEnvironment.ExtractedDir(archive); + if (Directory.Exists(folder)) return $"there is already a working copy at {folder}, though shops\\{name}.sds is not there - it may hold work that was never built; remove or rename it, or pick another name"; + CityShopsTable.Shop? source = _table.Shops.FirstOrDefault(s => Is(s, like)); + if (source == null) return $"the table has no interior named '{like}' to copy"; + if (source.Places.Count == 0) return $"{source.Name} has no place to make the new one like"; + FileInfo sourceArchive = ArchiveFile(ArchiveOf(source)); + if (!sourceArchive.Exists) return $"there is no shops\\{ArchiveOf(source)}.sds to copy"; + + string from = _ensureExtracted(sourceArchive); + if (SdsMeshLoader.OpenScene(from).FrameResource is not { } scene) return $"{sourceArchive.Name} has no scene"; + if (Find(scene, source.Holder) is not { } holder) return $"{sourceArchive.Name} has no frame named '{source.Holder}', which the table says carries the interior"; + string stem = name.ToUpperInvariant() + "_translocator_"; + if (scene.FrameObjects.Values.OfType().Any(f => (f.Name.String ?? "").StartsWith(stem, StringComparison.OrdinalIgnoreCase))) + return $"{sourceArchive.Name} already has frames named {stem}..."; + + // ---- everything can be done; from here the scene in memory is the new interior's + holder.Name = new HashName(stem + "00"); + // the markers of the places the copied interior stands at are not this one's places + foreach (CityShopsTable.Place place in source.Places) + { + if (Find(scene, place.Marker) is { Parent: null, Children.Count: 0 } marker && !ReferenceEquals(marker, holder)) Unlink(scene, marker); + } + var row = new CityShopsTable.Shop + { + Name = name, Holder = stem + "00", ActorFile = source.ActorFile, Description = source.Description, + Unk1 = source.Unk1, Unk2 = source.Unk2, Unk3 = source.Unk3, + }; + row.Entities.AddRange(source.Entities); + _table.Shops.Add(row); + _shopArchive = archive; + _shopScene = scene; + _copyFrom = from; + _copySource = sourceArchive; + if (AddPlace(row, source.Places[0], at, turnDegrees, loadHalf, unloadHalf, halfHeight, out made) is { } refused) + { + // nothing was written; this object is not to be saved + _table.Shops.Remove(row); + _shopArchive = null; + _shopScene = null; + _copyFrom = null; + _copySource = null; + return refused; + } + return null; + } + + /// + /// Takes an interior that was ADDED out of the table again, IN MEMORY: its row, its area rows and their + /// volumes. Its archive and working copy are left where they are - without the rows the game never asks for + /// them. An interior the game ships with is refused. writes it. + /// + /// Null on success, otherwise why not; nothing is changed on a refusal. + public string? Delete(string name, out ShopInfo? removed) + { + removed = null; + CityShopsTable.Shop? row = _table.Shops.FirstOrDefault(s => Is(s, name)); + if (row == null) return $"the table has no interior named '{name}'"; + if (IsShipped(row.Name)) return $"{row.Name} came with the game - only an interior that was added is taken out"; + if (_shopArchive != null) return $"a place of {Path.GetFileNameWithoutExtension(_shopArchive.Name)} is being changed - save it first"; + string archive = ArchiveOf(row); + List areas = [.. _table.Areas.Where(a => Same(a.Archive, archive))]; + if (areas.Any(a => LoadZones.IsShipped(a.LoadZone) || LoadZones.IsShipped(a.UnloadZone))) return $"{row.Name} is loaded by volumes the game ships with"; + removed = new ShopInfo(row.Name, ArchiveFile(archive).Exists ? archive : null, row.ActorFile, row.Entities.Count, + [.. row.Places.Select(p => Describe(row, archive, p, null))]); + foreach (CityShopsTable.Area area in areas) + { + _table.Areas.Remove(area); + foreach (string zone in new[] { area.LoadZone, area.UnloadZone }) + { + if (_table.Areas.Any(a => Same(a.LoadZone, zone) || Same(a.UnloadZone, zone))) continue; + if (Find(_cityScene, zone) is { } volume) Unlink(_cityScene, volume); + } + } + _table.Shops.Remove(row); + _tableOnly = true; + return null; + } + + /// Whether an interior of this name is one the game ships with (the main game's and Joe's Adventures'). + public static bool IsShipped(string? name) => + // without the list nothing can be told to be added - and what cannot be told is not taken out + name != null && (Shipped.Value.Count == 0 || Shipped.Value.Contains(name)); + + private static readonly Lazy> Shipped = new(() => + { + var names = new HashSet(StringComparer.OrdinalIgnoreCase); + System.Reflection.Assembly assembly = typeof(ShopPlaces).Assembly; + string? resource = assembly.GetManifestResourceNames().FirstOrDefault(n => n.EndsWith("ShippedInteriors.txt", StringComparison.Ordinal)); + if (resource == null) return names; + using Stream? stream = assembly.GetManifestResourceStream(resource); + if (stream == null) return names; + using var reader = new StreamReader(stream); + while (reader.ReadLine() is { } line) + { + if (line.Trim() is { Length: > 0 } name && name[0] != '#') names.Add(name); + } + return names; + }); + + /// + /// Writes what and changed into the working copies: the interior's + /// scene and name table, the scene and name table of city_univers, and the table - all of them, or, + /// when one cannot be written, none (the ones already swapped in are put back). A Build of both archives + /// takes it into the game. Returns the files written. + /// + /// A file could not be written; the message says what was left changed, if anything. + public IReadOnlyList Save() + { + if ((_shopArchive == null || _shopScene == null) && !_tableOnly) return []; + string cityDir = MafiaEnvironment.ExtractedDir(CityArchive); + string? shopDir = _shopArchive == null ? null : MafiaEnvironment.ExtractedDir(_shopArchive); + bool madeFolder = false; + if (_copyFrom != null && shopDir != null) + { + // a new interior: its working copy is the copied one's, made now - and taken away again if the rest fails + if (Directory.Exists(shopDir)) throw new IOException($"there is already a folder at {shopDir}"); + // the copy asks the engine for the memory its source asks: written beside the source, copied with it + if (_copySource != null) SdsWriter.EnsureMemoryRequirements(_copySource); + CopyFolder(_copyFrom, shopDir); + madeFolder = true; + } + List files = + [ + .. shopDir == null || madeFolder ? [] : SdsManifest.Load(shopDir).GetFiles("FrameResource").Take(1), + .. shopDir == null || madeFolder ? [] : SdsManifest.Load(shopDir).GetFiles("FrameNameTable").Take(1), + .. SdsManifest.Load(cityDir).GetFiles("FrameResource").Take(1), .. SdsManifest.Load(cityDir).GetFiles("FrameNameTable").Take(1), + _tableFile, + ]; + Dictionary before = files.ToDictionary(f => f, f => File.Exists(f) ? File.ReadAllBytes(f) : null, StringComparer.OrdinalIgnoreCase); + var written = new List(); + try + { + // the table first: it is the one that can refuse (names past the reach of their offsets) + byte[] table = _table.ToBytes(); + if (_shopArchive != null && _shopScene != null) + { + written.Add(SdsWriter.SaveFrameResource(_shopScene, _shopArchive)); + if (SdsWriter.SaveFrameNameTable(_shopScene, _shopArchive) is { } shopNames) written.Add(shopNames); + } + written.Add(SdsWriter.SaveFrameResource(_cityScene, CityArchive)); + if (SdsWriter.SaveFrameNameTable(_cityScene, CityArchive) is { } cityNames) written.Add(cityNames); + AtomicFile.WriteAllBytes(_tableFile, table); + written.Add(_tableFile); + return written; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException or InvalidDataException) + { + var left = new List(); + if (madeFolder) + { + // the new interior's folder was made by this save and holds nothing else + try + { + Directory.Delete(shopDir!, recursive: true); + } + catch (Exception again) when (again is IOException or UnauthorizedAccessException) + { + left.Add(shopDir!); + } + } + foreach (string file in written.Where(f => !madeFolder || !f.StartsWith(shopDir!, StringComparison.OrdinalIgnoreCase))) + { + try + { + if (before.GetValueOrDefault(file) is { } bytes) AtomicFile.WriteAllBytes(file, bytes); + } + catch (Exception again) when (again is IOException or UnauthorizedAccessException) + { + left.Add(Path.GetFileName(file)); + } + } + if (left.Count == 0) throw; + throw new IOException($"{ex.Message} - and the working copies could not be put back as they were ({string.Join(", ", left)}): unpack the archives again", ex); + } + } + + private static void CopyFolder(string from, string to) + { + Directory.CreateDirectory(to); + foreach (string dir in Directory.EnumerateDirectories(from, "*", SearchOption.AllDirectories)) + { + Directory.CreateDirectory(Path.Combine(to, Path.GetRelativePath(from, dir))); + } + foreach (string file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories)) + { + File.Copy(file, Path.Combine(to, Path.GetRelativePath(from, file))); + } + } + + // ---- the table's words + + private static bool Same(string? a, string? b) => string.Equals(a, b, StringComparison.OrdinalIgnoreCase); + + private bool Is(CityShopsTable.Shop shop, string asked) => Same(shop.Name, asked) || Same(ArchiveOf(shop), asked); + + // An interior's archive is named by the area rows, in their own (lower case) spelling of its name; an + // interior no area row names (one the story loads another way) has the file of its own name, if any. + private string ArchiveOf(CityShopsTable.Shop shop) => + _table.Areas.FirstOrDefault(a => Same(a.Archive, shop.Name))?.Archive ?? shop.Name.ToLowerInvariant(); + + private static FileInfo ArchiveFile(string archive) => new(Path.Combine(MafiaEnvironment.PcFolder, "sds", "shops", archive + ".sds")); + + private FrameResource? SceneOf(string archive) + { + if (_shopArchive != null && _shopScene != null && Same(Path.GetFileNameWithoutExtension(_shopArchive.Name), archive)) return _shopScene; + return SdsMeshLoader.OpenScene(_ensureExtracted(ArchiveFile(archive))).FrameResource; + } + + // One interior's archive is changed at a time: its scene is read once and every change is made in that copy. + private string? Hold(string archive) + { + FileInfo file = ArchiveFile(archive); + if (_shopArchive != null) + { + return Same(_shopArchive.FullName, file.FullName) ? null : $"a place of {Path.GetFileNameWithoutExtension(_shopArchive.Name)} is being changed - save it first"; + } + if (!file.Exists) return $"there is no shops\\{archive}.sds"; + if (SdsMeshLoader.OpenScene(_ensureExtracted(file)).FrameResource is not { } scene) return $"shops\\{archive}.sds has no scene"; + _shopArchive = file; + _shopScene = scene; + return null; + } + + private PlaceInfo Describe(CityShopsTable.Shop shop, string archive, CityShopsTable.Place place, FrameResource? scene) + { + Vector3? at = null; + float? turn = null; + CityShopsTable.Area? area = null; + if (scene != null && Find(scene, place.Marker) is { } frame) + { + Matrix4x4 world = frame.WorldTransform; + at = world.Translation; + turn = MathF.Atan2(world.M12, world.M11) * 180f / MathF.PI; + area = _table.Areas.FirstOrDefault(a => Same(a.Archive, archive) && Find(_cityScene, a.LoadZone) is FrameObjectArea volume + && LoadZones.Contains(volume, world.Translation, out _)); + } + bool added = area != null && !LoadZones.IsShipped(area.LoadZone); + return new PlaceInfo(shop.Name, archive, place.Marker, at, turn, place.MapX, place.MapY, area?.LoadZone, area?.UnloadZone, added); + } + + // ---- the scenes + + private static FrameObjectBase? Find(FrameResource scene, string name) => + scene.FrameObjects.Values.OfType().FirstOrDefault(f => Same(f.Name.String, name)); + + // A frame matrix is kept as three columns; for arithmetic it is a whole matrix. + private static Matrix4x4 Whole(Matrix4x4 m) + { + m.M14 = 0f; + m.M24 = 0f; + m.M34 = 0f; + m.M44 = 1f; + return m; + } + + private bool Square(CityShopsTable.Area row) + { + foreach (string name in new[] { row.LoadZone, row.UnloadZone }) + { + if (Find(_cityScene, name) is not FrameObjectArea volume) return false; + Matrix4x4 world = volume.WorldTransform; + if (MathF.Abs(world.M11 - 1f) > 1e-3f || MathF.Abs(world.M22 - 1f) > 1e-3f || MathF.Abs(world.M33 - 1f) > 1e-3f) return false; + } + return true; + } + + // The next marker of an interior: the name its markers share, with the number after the highest in use. + private string NextMarker(FrameResource scene, CityShopsTable.Shop shop, string like) + { + int digits = 0; + while (digits < like.Length && char.IsAsciiDigit(like[like.Length - 1 - digits])) digits++; + string stem = digits > 0 ? like[..^digits] : like + "_"; + int width = Math.Max(digits, 2), highest = -1; + IEnumerable names = scene.FrameObjects.Values.OfType().Select(f => f.Name.String ?? "") + .Concat(shop.Places.Select(p => p.Marker)).Append(shop.Holder); + foreach (string name in names) + { + if (name.StartsWith(stem, StringComparison.OrdinalIgnoreCase) + && int.TryParse(name.AsSpan(stem.Length), NumberStyles.None, CultureInfo.InvariantCulture, out int number)) + { + highest = Math.Max(highest, number); + } + } + return stem + (highest + 1).ToString("D" + width, CultureInfo.InvariantCulture); + } + + // The next pair of volume names, written as the game writes them: AREA_1_ and AREA_0_, + // with numbers no object of the scene has - from 900 up, clear of the game's own. + private (string Load, string Unload) NextZones(string like, string shop) + { + int cut = like.IndexOf("_1_", StringComparison.Ordinal); + string word = cut >= 0 && cut + 3 < like.Length ? like[(cut + 3)..] : shop; + HashSet taken = new(_cityScene.FrameObjects.Values.OfType().Select(f => f.Name.String ?? ""), StringComparer.OrdinalIgnoreCase); + bool Free(int n) => !taken.Any(name => name.StartsWith("AREA" + n.ToString(CultureInfo.InvariantCulture) + "_", StringComparison.OrdinalIgnoreCase)); + int number = 900; + while (!Free(number) || !Free(number + 1)) number += 2; + return ($"AREA{number}_1_{word}", $"AREA{number + 1}_0_{word}"); + } + + // A box volume made like an existing one - its flags, its parent, its place in the name table - with a + // name, a centre and a size of its own (as LoadZones.Create makes a load zone). + private void AddVolume(FrameObjectArea source, string name, Vector3 centre, Vector3 half) + { + Matrix4x4 world = source.WorldTransform; + Matrix4x4 parent = (source.Parent ?? source.Root)?.WorldTransform ?? Matrix4x4.Identity; + parent.Translation = Vector3.Zero; + Matrix4x4.Invert(Whole(parent), out Matrix4x4 unTurn); + + var volume = new FrameObjectArea(source) { Name = new HashName(name) }; + var box = volume.Bounds; + box.Min = -half; + box.Max = half; + volume.Bounds = box; + // inside is n.p + d >= 0 in the volume's own space + volume.Planes = + [ + new Vector4(-1, 0, 0, half.X), new Vector4(1, 0, 0, half.X), + new Vector4(0, -1, 0, half.Y), new Vector4(0, 1, 0, half.Y), + new Vector4(0, 0, -1, half.Z), new Vector4(0, 0, 1, half.Z), + ]; + volume.PlaneSize = volume.Planes.Length; + _cityScene.FrameObjects.Add(volume.RefID, volume); + FrameDuplicator.LinkParents(volume, FrameDuplicator.ResolveRef(_cityScene, source, FrameEntryRefTypes.Parent1), FrameDuplicator.ResolveRef(_cityScene, source, FrameEntryRefTypes.Parent2)); + Matrix4x4 local = source.LocalTransform; + local.Translation += Vector3.TransformNormal(centre - world.Translation, unTurn); + volume.LocalTransform = local; + } + + private static void Unlink(FrameResource scene, FrameObjectBase frame) + { + frame.SetParent(ParentInfo.ParentType.ParentIndex1, null); + frame.SetParent(ParentInfo.ParentType.ParentIndex2, null); + foreach (FrameHeaderScene folder in scene.FrameScenes.Values) folder.Children.Remove(frame); + scene.FrameObjects.Remove(frame.RefID); + } +} diff --git a/src/Illusion.Formats/City/CityShopsTable.cs b/src/Illusion.Formats/City/CityShopsTable.cs new file mode 100644 index 0000000..922ad71 --- /dev/null +++ b/src/Illusion.Formats/City/CityShopsTable.cs @@ -0,0 +1,258 @@ +using System.Text; + +namespace Illusion.Formats.City; + +/// +/// cityshops.bin read into its rows and written back as it stands: the table of the interiors the game +/// keeps under shops\ and stands in the open city - shops, bars, flats, garages. +/// +/// One such archive can stand at several PLACES. Each place is a marker frame in the archive itself (the gun +/// shop's GUNSHOP_translocator_01, _02, ...) and a pair of volumes in the scene of +/// city_univers: inside the first the interior is loaded, outside the wider second it is let go. This +/// table names them: an AREA row per pair (the two volumes and the archive), and per interior a row with its +/// actor file, the entities of that file and its places. +/// +/// +/// Layout: "hstc", version (8; 9 in Joe's Adventures), area count, byte length of the names, shop count, +/// three more numbers; the names, each ended by a zero and addressed by its offset; the area rows - two +/// zero-ended strings, (version 9: a byte,) a name offset; then the shop rows - a zero-ended name, a name +/// offset (the marker that carries the interior), two zero-ended strings, a 16-bit and two 32-bit numbers, the +/// entity names, and the places: a name offset, two floats (where the map draws it), an icon, a text, (version +/// 9: a byte,) and one 16-bit mark per entity. Of the header's three numbers the first is the number of places +/// in the whole table and the third the number of marks; the second is kept as read. +/// +/// +public sealed class CityShopsTable +{ + private const uint Magic = 0x63747368; // "hstc" + private static readonly Encoding Text = Encoding.Latin1; + + private readonly int _version; + private readonly int _kept; // the header's middle number, whose meaning is not known + private readonly List _names = []; // the name buffer, in the order it was read + + /// A pair of volumes of city_univers and the archive under shops\ they load. + public sealed class Area + { + public required string LoadZone { get; set; } + public required string UnloadZone { get; set; } + public required string Archive { get; set; } + /// Version 9 only: a byte of the row, kept as read. + public byte Pad { get; set; } + } + + /// One place an interior stands at. + public sealed class Place + { + /// The marker frame of the interior's archive that stands at the place. + public required string Marker { get; set; } + /// Where the map draws the place. + public float MapX { get; set; } + public float MapY { get; set; } + public int Icon { get; set; } + public int TextId { get; set; } + /// One number per entity of the interior, in the order of . + public short[] Marks { get; set; } = []; + /// Version 9 only: a byte of the row, kept as read. + public byte Pad { get; set; } + } + + /// An interior: its actor file, the entities of that file, and the places it stands at. + public sealed class Shop + { + public required string Name { get; set; } + /// The marker frame the interior itself hangs on. + public required string Holder { get; set; } + public string ActorFile { get; set; } = ""; + public string Description { get; set; } = ""; + public short Unk1 { get; set; } + public int Unk2 { get; set; } + public int Unk3 { get; set; } + public List Entities { get; } = []; + public List Places { get; } = []; + } + + public int Version => _version; + public List Areas { get; } = []; + public List Shops { get; } = []; + + private CityShopsTable(int version, int kept) + { + _version = version; + _kept = kept; + } + + /// The bytes are not a city shops table. + public static CityShopsTable Parse(ReadOnlySpan bytes) + { + if (bytes.Length < 32 || BitConverter.ToUInt32(bytes) != Magic) throw new FileFormatException("not a cityshops.bin (no \"hstc\" at its start)"); + int version = BitConverter.ToInt32(bytes[4..]), areas = BitConverter.ToInt32(bytes[8..]), namesLength = BitConverter.ToInt32(bytes[12..]); + int shops = BitConverter.ToInt32(bytes[16..]); + if (version is not (8 or 9)) throw new FileFormatException($"cityshops.bin: version {version} is not one this reads (8, 9)"); + if (areas < 0 || shops < 0 || namesLength < 0 || namesLength > ushort.MaxValue || 32 + namesLength > bytes.Length) + { + throw new FileFormatException("cityshops.bin: its header does not fit the file"); + } + + var table = new CityShopsTable(version, BitConverter.ToInt32(bytes[24..])); + var byOffset = new Dictionary(); + int at = 32; + while (at - 32 < namesLength) + { + int offset = at - 32; + string name = ReadText(bytes, ref at); + byOffset[offset] = name; + table._names.Add(name); + } + if (at - 32 != namesLength) throw new FileFormatException("cityshops.bin: its names run past the length the header gives"); + + string Named(ReadOnlySpan all, ref int position) + { + int key = ReadUInt16(all, ref position); + return byOffset.TryGetValue(key, out string? name) ? name : throw new FileFormatException($"cityshops.bin: no name starts at offset {key}"); + } + + bool v9 = version == 9; + for (int i = 0; i < areas; i++) + { + string load = ReadText(bytes, ref at), unload = ReadText(bytes, ref at); + byte pad = v9 ? ReadByte(bytes, ref at) : (byte)0; + table.Areas.Add(new Area { LoadZone = load, UnloadZone = unload, Archive = Named(bytes, ref at), Pad = pad }); + } + for (int i = 0; i < shops; i++) + { + string name = ReadText(bytes, ref at); + var shop = new Shop { Name = name, Holder = Named(bytes, ref at) }; + shop.ActorFile = ReadText(bytes, ref at); + shop.Description = ReadText(bytes, ref at); + shop.Unk1 = (short)ReadUInt16(bytes, ref at); + shop.Unk2 = ReadInt32(bytes, ref at); + shop.Unk3 = ReadInt32(bytes, ref at); + int entities = ReadInt32(bytes, ref at); + if (entities < 0 || entities > bytes.Length) throw new FileFormatException($"cityshops.bin: '{name}' counts {entities} entities"); + for (int e = 0; e < entities; e++) shop.Entities.Add(ReadText(bytes, ref at)); + int places = ReadInt32(bytes, ref at); + if (places < 0 || places > bytes.Length) throw new FileFormatException($"cityshops.bin: '{name}' counts {places} places"); + for (int p = 0; p < places; p++) + { + var place = new Place { Marker = Named(bytes, ref at) }; + place.MapX = BitConverter.Int32BitsToSingle(ReadInt32(bytes, ref at)); + place.MapY = BitConverter.Int32BitsToSingle(ReadInt32(bytes, ref at)); + place.Icon = ReadInt32(bytes, ref at); + place.TextId = ReadInt32(bytes, ref at); + if (v9) place.Pad = ReadByte(bytes, ref at); + place.Marks = new short[entities]; + for (int e = 0; e < entities; e++) place.Marks[e] = (short)ReadUInt16(bytes, ref at); + shop.Places.Add(place); + } + table.Shops.Add(shop); + } + if (at != bytes.Length) throw new FileFormatException($"cityshops.bin: {bytes.Length - at} byte(s) left over after its rows"); + return table; + } + + public byte[] ToBytes() + { + // The names keep the order they were read in, so an untouched table comes back byte for byte. A name no + // row uses any more is dropped, a new one goes to the end. + var used = new HashSet(StringComparer.Ordinal); + foreach (Area a in Areas) used.Add(a.Archive); + foreach (Shop s in Shops) + { + used.Add(s.Holder); + foreach (Place p in s.Places) used.Add(p.Marker); + } + var offsets = new Dictionary(StringComparer.Ordinal); + using var names = new MemoryStream(); + foreach (string name in _names.Where(used.Contains).Concat(used.Except(_names).OrderBy(n => n, StringComparer.Ordinal))) + { + if (offsets.ContainsKey(name)) continue; + offsets[name] = (int)names.Length; + names.Write(Text.GetBytes(name)); + names.WriteByte(0); + } + if (names.Length > ushort.MaxValue) throw new InvalidOperationException("cityshops.bin: its names no longer fit the 16-bit offsets that address them"); + + bool v9 = _version == 9; + using var output = new MemoryStream(); + using var writer = new BinaryWriter(output, Text, leaveOpen: true); + writer.Write(Magic); + writer.Write(_version); + writer.Write(Areas.Count); + writer.Write((int)names.Length); + writer.Write(Shops.Count); + writer.Write(Shops.Sum(s => s.Places.Count)); + writer.Write(_kept); + writer.Write(Shops.Sum(s => s.Places.Sum(p => p.Marks.Length))); + writer.Write(names.ToArray()); + void WriteText(string text) + { + writer.Write(Text.GetBytes(text)); + writer.Write((byte)0); + } + foreach (Area a in Areas) + { + WriteText(a.LoadZone); + WriteText(a.UnloadZone); + if (v9) writer.Write(a.Pad); + writer.Write((ushort)offsets[a.Archive]); + } + foreach (Shop s in Shops) + { + WriteText(s.Name); + writer.Write((ushort)offsets[s.Holder]); + WriteText(s.ActorFile); + WriteText(s.Description); + writer.Write(s.Unk1); + writer.Write(s.Unk2); + writer.Write(s.Unk3); + writer.Write(s.Entities.Count); + foreach (string entity in s.Entities) WriteText(entity); + writer.Write(s.Places.Count); + foreach (Place p in s.Places) + { + if (p.Marks.Length != s.Entities.Count) throw new InvalidOperationException($"cityshops.bin: place '{p.Marker}' of '{s.Name}' has {p.Marks.Length} marks for {s.Entities.Count} entities"); + writer.Write((ushort)offsets[p.Marker]); + writer.Write(p.MapX); + writer.Write(p.MapY); + writer.Write(p.Icon); + writer.Write(p.TextId); + if (v9) writer.Write(p.Pad); + foreach (short mark in p.Marks) writer.Write(mark); + } + } + writer.Flush(); + return output.ToArray(); + } + + private static string ReadText(ReadOnlySpan bytes, ref int at) + { + int length = at <= bytes.Length ? bytes[at..].IndexOf((byte)0) : -1; + if (length < 0) throw new FileFormatException("cityshops.bin: a name runs past the end of the file"); + string text = Text.GetString(bytes.Slice(at, length)); + at += length + 1; + return text; + } + + private static byte ReadByte(ReadOnlySpan bytes, ref int at) + { + if (at + 1 > bytes.Length) throw new FileFormatException("cityshops.bin: a row runs past the end of the file"); + return bytes[at++]; + } + + private static ushort ReadUInt16(ReadOnlySpan bytes, ref int at) + { + if (at + 2 > bytes.Length) throw new FileFormatException("cityshops.bin: a row runs past the end of the file"); + ushort value = BitConverter.ToUInt16(bytes[at..]); + at += 2; + return value; + } + + private static int ReadInt32(ReadOnlySpan bytes, ref int at) + { + if (at + 4 > bytes.Length) throw new FileFormatException("cityshops.bin: a row runs past the end of the file"); + int value = BitConverter.ToInt32(bytes[at..]); + at += 4; + return value; + } +} diff --git a/src/Illusion.Formats/Materials/MaterialParameter.cs b/src/Illusion.Formats/Materials/MaterialParameter.cs index 3ee7027..06acf71 100644 --- a/src/Illusion.Formats/Materials/MaterialParameter.cs +++ b/src/Illusion.Formats/Materials/MaterialParameter.cs @@ -28,7 +28,8 @@ public MaterialParameter() public MaterialParameter(MaterialParameter OtherParameter) { ID = OtherParameter.ID; - Paramaters = OtherParameter.paramaters; + // values of its own: a copy that shared the array changed the original with every edit + Paramaters = [.. OtherParameter.paramaters ?? []]; } public override string ToString() diff --git a/src/Illusion.Formats/Materials/Versions/IMaterial.cs b/src/Illusion.Formats/Materials/Versions/IMaterial.cs index b518288..f3004cf 100644 --- a/src/Illusion.Formats/Materials/Versions/IMaterial.cs +++ b/src/Illusion.Formats/Materials/Versions/IMaterial.cs @@ -144,7 +144,7 @@ public IMaterialSampler() public IMaterialSampler(IMaterialSampler OtherSampler) { ID = OtherSampler.ID; - SamplerStates = OtherSampler.SamplerStates; + SamplerStates = [.. OtherSampler.SamplerStates ?? []]; } public virtual MaterialVersion GetVersion() diff --git a/src/Illusion.Formats/Materials/Versions/Material_v57.cs b/src/Illusion.Formats/Materials/Versions/Material_v57.cs index 4c00d73..ed8f8a6 100644 --- a/src/Illusion.Formats/Materials/Versions/Material_v57.cs +++ b/src/Illusion.Formats/Materials/Versions/Material_v57.cs @@ -179,26 +179,26 @@ public MaterialSampler_v57() : base() public MaterialSampler_v57(IMaterialSampler OtherSampler) : base(OtherSampler) { ID = OtherSampler.ID; - SamplerStates = OtherSampler.SamplerStates; + SamplerStates = [.. OtherSampler.SamplerStates ?? []]; // TODO: Setup is essentially the same, maybe we can somehow make v57 and v58 share the same interface? if (OtherSampler.GetVersion() == MaterialVersion.V_57) { MaterialSampler_v57 CastedSampler = (OtherSampler as MaterialSampler_v57)!; - UnkSet0 = CastedSampler.UnkSet0; + UnkSet0 = [.. CastedSampler.UnkSet0 ?? []]; TextureName = new HashName(CastedSampler.TextureName); TexType = CastedSampler.TexType; UnkZero = CastedSampler.UnkZero; - UnkSet1 = CastedSampler.UnkSet1; + UnkSet1 = [.. CastedSampler.UnkSet1 ?? []]; } else if (OtherSampler.GetVersion() == MaterialVersion.V_58) { MaterialSampler_v58 CastedSampler = (OtherSampler as MaterialSampler_v58)!; - UnkSet0 = CastedSampler.UnkSet0; + UnkSet0 = [.. CastedSampler.UnkSet0 ?? []]; TextureName = new HashName(CastedSampler.TextureName); TexType = CastedSampler.TexType; UnkZero = CastedSampler.UnkZero; - UnkSet1 = CastedSampler.UnkSet1; + UnkSet1 = [.. CastedSampler.UnkSet1 ?? []]; } else { diff --git a/src/Illusion.Formats/Materials/Versions/Material_v58.cs b/src/Illusion.Formats/Materials/Versions/Material_v58.cs index 6a09e41..23f853a 100644 --- a/src/Illusion.Formats/Materials/Versions/Material_v58.cs +++ b/src/Illusion.Formats/Materials/Versions/Material_v58.cs @@ -182,7 +182,7 @@ public MaterialSampler_v58() : base() public MaterialSampler_v58(IMaterialSampler OtherSampler) : base(OtherSampler) { ID = OtherSampler.ID; - SamplerStates = OtherSampler.SamplerStates; + SamplerStates = [.. OtherSampler.SamplerStates ?? []]; // TODO: Setup is essentially the same, maybe we can somehow make v57 and v58 share the same interface? if (OtherSampler.GetVersion() == MaterialVersion.V_57) @@ -191,7 +191,7 @@ public MaterialSampler_v58(IMaterialSampler OtherSampler) : base(OtherSampler) TextureName = new HashName(CastedSampler.TextureName); TexType = CastedSampler.TexType; UnkZero = CastedSampler.UnkZero; - UnkSet1 = CastedSampler.UnkSet1; + UnkSet1 = [.. CastedSampler.UnkSet1 ?? []]; UnkSet0 = new int[4]; Array.Copy(CastedSampler.UnkSet0, 0, UnkSet0, 0, 2); @@ -202,10 +202,11 @@ public MaterialSampler_v58(IMaterialSampler OtherSampler) : base(OtherSampler) TextureName = new HashName(CastedSampler.TextureName); TexType = CastedSampler.TexType; UnkZero = CastedSampler.UnkZero; - UnkSet1 = CastedSampler.UnkSet1; + UnkSet1 = [.. CastedSampler.UnkSet1 ?? []]; UnkSet0 = new int[4]; - Array.Copy(CastedSampler.UnkSet0, 0, UnkSet0, 0, 2); + // all of them: this version keeps four where version 57 keeps two + Array.Copy(CastedSampler.UnkSet0, 0, UnkSet0, 0, Math.Min(UnkSet0.Length, CastedSampler.UnkSet0.Length)); } else { diff --git a/src/Illusion.Formats/Prefab/CarPrefab.cs b/src/Illusion.Formats/Prefab/CarPrefab.cs index 0028ded..6a37583 100644 --- a/src/Illusion.Formats/Prefab/CarPrefab.cs +++ b/src/Illusion.Formats/Prefab/CarPrefab.cs @@ -47,6 +47,21 @@ public sealed class CarPrefab public IReadOnlyList Wipers => _car.WipersFrameName; + /// + /// The car's lights as the game reads them: the frame (a bone) whose piece of the body is lit, two numbers + /// that say what kind of light it is and which group it switches with, how strongly the piece glows and + /// how it pulses, the bones that must be whole for it to work, and the model of the light it casts. + /// The meaning of the unnamed numbers is not established; they are given as stored. + /// + public IReadOnlyList Lights => + [.. _car.ShaderEffects.SelectMany(e => e.Lights).Select(l => new Light(l.FrameName, l.Unk1, l.Unk2, l.Unk3, l.Unk4, + l.EmissivePower, l.EmissiveMiddle, l.EmissiveSpeed0, l.EmissiveSpeed1, [.. l.CheckBoneName], l.LightModelHash, + l.ParticleBreakId, l.Unk12))]; + + /// Where the car's light sources stand, in the model's space: a place and three axes each. + public IReadOnlyList<(Vector3 Translation, Vector3 Row0, Vector3 Row1, Vector3 Row2)> LightMatrices => + [.. (Other?.LightMatrices ?? []).Select(m => (m.Translation, m.Row0, m.Row1, m.Row2))]; + /// How many deformable parts the body is split into (the damage model). public int DeformPartCount => Deformation?.DeformParts.Count ?? 0; @@ -71,6 +86,10 @@ public sealed class CarPrefab [.. _car.Axles.Select(a => new Axle(a.AxleName, a.BrakeDrumName, a.RotWingName, a.AxleType, a.Wheel.BrakeDrumRadius, a.Wheel.BrakeDrumMass, a.Wheel.AxleMass))]; + /// One light of the car (see ). + public sealed record Light(ulong Frame, int Unk1, int Unk2, uint Unk3, uint Unk4, float EmissivePower, float EmissiveMiddle, + float EmissiveSpeed0, float EmissiveSpeed1, IReadOnlyList CheckBones, ulong LightModel, uint ParticleBreakId, uint Unk12); + /// Every frame-name hash this prefab references, for resolving against the archive's frames. public IEnumerable AllFrameReferences { diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index e864ff1..ec8c469 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -155,6 +155,24 @@ public sealed record CameraInfo(float[] Position, float Yaw, float Pitch, float public sealed record LoadZoneInfo(string Name, IReadOnlyList Districts, bool Inside, float OutsideBy, float[] BoxMin, float[] BoxMax, bool LoadsOnArrival = false, bool Added = false); +/// What an archive's meshes are drawn with: how many materials it names and how they stand against the game +/// as it ships ( false: no list for this edition, the counts by origin are then zero), +/// the hashes no library has, and the document for whoever takes the archive - by default only the materials that +/// have to travel with it. is the file the document was written to, if one was asked for. +public sealed record ArchiveMaterialsInfo(string Archive, string Path, bool OriginKnown, int Used, int Added, int Changed, int Shipped, int Missing, + System.Text.Json.Nodes.JsonObject Document, string? SavedTo, string? Library = null, int LibraryMaterials = 0); + +/// One place an interior of shops\ stands at: the marker frame of its archive, where that marker +/// stands and how it is turned about the vertical (degrees; both null when the archive was not read), where the map +/// draws the place, and the pair of volumes of city_univers that load and let go of the interior there. +/// is false for a place the game ships with. +public sealed record ShopPlaceInfo(string Shop, string Archive, string Marker, float[]? At, float? Turn, float[] Map, string? LoadZone, string? UnloadZone, + bool Added); + +/// An interior of cityshops.bin: its name there, its archive under shops\ (null when there is no such +/// file), its actor file, how many entities that file has, and its places. +public sealed record ShopInfo(string Name, string? Archive, string ActorFile, int Entities, IReadOnlyList Places); + /// A face of a load zone moved - or, when not applied, what moving it would do: from where to where on /// its axis (world), the zone's box afterwards, the districts it asks for, and the working-copy file written. /// is which copy of city_univers.sds this is about: "base" or a DLC's folder name. diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index a6ce141..f00bcef 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -99,11 +99,13 @@ public interface IEditorSession /// Null on success. /// /// The source .sds: a full path, or one relative to the game's sds folder. - /// Heading about the vertical axis, replacing the original's rotation; null keeps - /// the rotation the original has. + /// Heading about the vertical axis, replacing the original's heading and keeping + /// its tilt; null keeps the heading the original has as well. /// For scenery: auto (its own hulls, else its convex hull), convex, box, mesh or none. + /// A frame of the receiving archive to hang a piece of scenery under; null stands it + /// in the scene by itself. string? ImportObject(string sourceArchive, string name, string newName, float[] position, float? yawDegrees, - string? collision, int occurrence, out ObjectImportOutcome? outcome); + string? collision, int occurrence, string? parent, out ObjectImportOutcome? outcome); /// Duplicates the selection (undoable) and leaves the copies selected. Null on success. string? DuplicateSelected(out IReadOnlyList copies); @@ -216,4 +218,40 @@ public interface IEditorSession /// volume, its place in the frame name table and its line in cityareas.bin. Without /// nothing is written. A zone the game ships with is refused. Null on success. string? ZoneDelete(string name, bool apply, out LoadZoneInfo? zone); + + /// The interiors the game keeps under shops\ and the places they stand at, as cityshops.bin has + /// them. For the one named by its archive is read too, so its places come with where + /// their markers stand and which volumes hold them. Null on success. + string? ShopPlaces(string? shop, out IReadOnlyList shops); + + /// Adds a place for an interior: a marker in its archive at , a pair of box + /// volumes round it in city_univers, and the rows of cityshops.bin. Without nothing is + /// written. With it the working copies of both archives are saved; names the two to + /// build. Null on success. + string? ShopPlaceAdd(string shop, float[] point, float turn, float loadHalf, float unloadHalf, float halfHeight, bool apply, + out ShopPlaceInfo? place, out IReadOnlyList archives); + + /// Takes a place that was ADDED out again: its marker, its volumes and its rows. A place the game ships + /// with is refused. Without nothing is written. Null on success. + string? ShopPlaceDelete(string marker, bool apply, out ShopPlaceInfo? place, out IReadOnlyList archives); + + /// Makes an interior of one's own: a copy of one of the table's interiors under a new name - an + /// archive of its own under shops\ and a row of its own - standing at . + /// Without nothing is written. With it the working copies are saved and the NEW + /// archive is packed (nothing of the game is overwritten); names what is still + /// to be built, what the caller should know. Null on success. + string? ShopCreate(string name, string like, float[] point, float turn, float loadHalf, float unloadHalf, float halfHeight, bool apply, + out ShopPlaceInfo? place, out IReadOnlyList archives, out IReadOnlyList notes); + + /// Takes an interior that was ADDED out of the table again: its row, its area rows and their volumes. + /// Its archive and working copy are left where they are. Without nothing is + /// written. Null on success. + string? ShopDelete(string name, bool apply, out ShopInfo? shop, out IReadOnlyList archives); + + /// The materials an archive's meshes are drawn with, read from the archive file as it stands and the + /// material libraries on disk: each in full, told as the game's own, changed or added. + /// is a full path or a path under pc\sds. With the document is also written to + /// that file, and with the added and changed materials are written as a material + /// library (.mtl) of their own. Nothing of the game is written. Null on success. + string? ArchiveMaterials(string archive, bool all, string? saveTo, string? libraryTo, out ArchiveMaterialsInfo? result); } diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 4b197e8..8de8796 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -846,7 +846,7 @@ public static async Task DuplicateSelected(IEditorSession editor, IUiThr } [McpServerTool(Name = "actor_import")] - [Description("Copy an actor out of ANOTHER archive's actor pack into the loaded area, with its own copy of its behaviour row — how a district with no lights is given one from a stock interior (a LightEntity). Only actors that place no object of their own scene travel this way: lights, sounds — for one that places an object (a door, a prop) use object_import. Undoable. Find candidates with decode_actors on the source .act file.")] + [Description("Copy an actor out of ANOTHER archive's actor pack into the loaded area, with its own copy of its behaviour row — how a district with no lights is given one from a stock interior (a LightEntity). With the resource editor as the target (editor_target) the copy goes into the archive open there instead — how an interior under shops\\ gets its lights. Only actors that place no object of their own scene travel this way: lights, sounds — for one that places an object (a door, a prop) use object_import. Undoable. Find candidates with decode_actors on the source .act file.")] public static async Task ImportActor( IEditorSession editor, IUiThreadMarshal ui, @@ -869,7 +869,7 @@ public static async Task ImportActor( } [McpServerTool(Name = "object_import")] - [Description("Copy an object out of ANOTHER archive into the loaded area: a door from a shop, a prop or a piece of furniture from an interior. 'name' is looked up first among the source archive's actors (entity name, as decode_actors lists it) — then the actor comes too, with the object it places, its behaviour row, its prefab entry and the item descriptions its collision hulls name — and otherwise among its scene's frame objects (as decode_frame_resource lists them), which arrive as plain scenery anchored to the district's scene, with the source's collision hulls that stand inside their footprint — or, when they had none, a hull cooked from their triangles. Geometry is copied into the area's own buffer pools and the textures its materials name into its working copy, so the object does not depend on the source archive being loaded. Undoable: undone and saved, the textures it brought are set aside (and come back if it is redone), while the item descriptions and the prefab entry stay in the working copy, unused. An import that is refused or fails leaves nothing — neither in the scene nor in the working copy. Skinned models cannot travel yet.")] + [Description("Copy an object out of ANOTHER archive into the loaded area — or, with the resource editor as the target (editor_target), into the archive open there, which is how an interior under shops\\ is furnished: a door from a shop, a prop or a piece of furniture from an interior. 'name' is looked up first among the source archive's actors (entity name, as decode_actors lists it) — then the actor comes too, with the object it places, its behaviour row, its prefab entry and the item descriptions its collision hulls name — and otherwise among its scene's frame objects (as decode_frame_resource lists them), which arrive as plain scenery anchored to the district's scene, with the source's collision hulls that stand inside their footprint — or, when they had none, a hull cooked from their triangles. Geometry is copied into the area's own buffer pools and the textures its materials name into its working copy, so the object does not depend on the source archive being loaded. Undoable: undone and saved, the textures it brought are set aside (and come back if it is redone), while the item descriptions and the prefab entry stay in the working copy, unused. An import that is refused or fails leaves nothing — neither in the scene nor in the working copy. Skinned models cannot travel yet.")] public static async Task ImportObject( IEditorSession editor, IUiThreadMarshal ui, @@ -877,18 +877,19 @@ public static async Task ImportObject( [Description("Entity name of an actor in the source archive, or the name of a frame object in its scene.")] string name, [Description("Name for the copy; must be new in the loaded area (it names both the object and, for an actor, the actor).")] string newName, [Description("World position [x, y, z] to put it at: for an actor the point it places its object at (stock props stand on it); for scenery the point the middle of its base lands on.")] float[] position, - [Description("Heading in degrees about the vertical axis, replacing the original's rotation. Omit to keep the rotation the original has.")] float? yawDegrees = null, + [Description("Heading in degrees about the vertical axis. It replaces the heading the original has in its source archive and keeps its tilt, so what stands upright there stands upright here whichever way its mesh lies in its own space (a chair modelled on its back and stood up by its frame's matrix stays on its legs). 0 is the object as it stands there with its own turn about the vertical taken out — for one that is upright in its own space, no rotation at all. For scenery the middle of the bottom of the object as it stands lands on 'position'. Omit to keep the heading the original has as well.")] float? yawDegrees = null, [Description("Collision for scenery: 'auto' (default — the hulls that stand inside its box in the source archive, taken as its own, else its convex hull; for a shelf or a room that includes the hulls of what stood on or in it), 'convex' (a few dozen triangles shrink-wrapping it), 'box', 'mesh' (every render triangle) or 'none'. An actor's object always brings its own.")] string? collision = null, - [Description("Which of the things named so in the source, counting from 1 — names repeat (87 bottles called 'lahev' in one bar). Actors of that name come first, then frame objects, the ones that draw before helpers. The result says how many there are (NamedSo). Default 1.")] int occurrence = 1) + [Description("Which of the things named so in the source, counting from 1 — names repeat (87 bottles called 'lahev' in one bar). Actors of that name come first, then frame objects, the ones that draw before helpers. The result says how many there are (NamedSo). Default 1.")] int occurrence = 1, + [Description("Scenery only: a frame of the receiving archive to hang the copy under (name or path suffix) instead of standing it in the scene by itself. An interior's pieces are children of its holder — the '…_translocator_00' frame, which carries them to every place the interior stands at — so furniture for an interior takes the holder here. The copy still lands on 'position' in the archive's own world, and it is not put on the spawn list. Omit for a district.")] string? parent = null) { try { if (position is not { Length: 3 }) return ToolResult.Invalid("position takes three numbers"); ObjectImportOutcome? outcome = null; string? refused = await ui.RunAsync( - () => editor.ImportObject(sourceArchive, name, newName, position, yawDegrees, collision, occurrence, out outcome)); + () => editor.ImportObject(sourceArchive, name, newName, position, yawDegrees, collision, occurrence, parent, out outcome)); if (refused != null) return ToolResult.Invalid(refused); - return ToolResult.Json(new { success = true, imported = outcome, status = await ui.RunAsync(editor.Status) }); + return ToolResult.Json(new { success = true, imported = outcome, status = await StatusOf(editor, ui) }); } catch (Exception ex) { @@ -1087,4 +1088,141 @@ public static async Task ZoneDelete( return ToolResult.Fail(ex); } } + + [McpServerTool(Name = "shop_places")] + [Description("The INTERIORS the game stands in the open city - gun shops, clothes shops, diners, bars, garages, flats - and the places they stand at. Such an interior is an archive under shops\\ that the game loads when the player comes near; one archive can stand at several places (the gun shop at eleven). Without a name: every interior of cityshops.bin with its archive, its actor file and its places as the table has them (marker name, map position). With 'shop': that interior's archive is read too, so each place comes with where its marker stands, how it is turned, the pair of volumes of city_univers that load it there ('LoadZone') and let it go ('UnloadZone'), and whether it was added here ('Added'). A place is added with shop_place_add.")] + public static async Task ShopPlaces( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("An interior's name as the table has it ('Gunshop') or its archive's ('gunshop'). Omit for the list of all.")] string? shop = null) + { + try + { + IReadOnlyList shops = []; + string? refused = await ui.RunAsync(() => editor.ShopPlaces(shop, out shops)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, count = shops.Count, shops }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "shop_place_add")] + [Description("Stand an interior of shops\\ at ONE MORE PLACE of the city - the way the game itself stands one gun shop at eleven. Three things are written together: a marker frame in the interior's archive at 'point', a pair of box volumes round it in city_univers.sds (inside the smaller the interior is loaded, outside the wider it is let go), and the rows of cityshops.bin. 'point' is where the interior's OWN ORIGIN goes - see with shop_places how an existing marker stands against its room (the gun shop's is 2.08 m above its floor, in the middle of the room). By default it only REPORTS; apply=true writes the working copies - then archive_build BOTH archives named in the answer. Refused while either archive is open in an editor. Seen in the game (a multiplayer client): a twelfth gun shop added this way loads and its shop menu opens; an interior has no outside, so it is see-through from the street unless it stands inside a building. 'turn' follows the game's own markers, which are turned; a turned place added here has not been looked at in the game yet. Take a place out again with shop_place_delete.")] + public static async Task ShopPlaceAdd( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The interior, as shop_places lists it: 'Gunshop', 'Odevy', 'vitohouseb12'...")] string shop, + [Description("Where the interior's own origin goes, world [x, y, z].")] float[] point, + [Description("Turn about the vertical, degrees, counter-clockwise seen from above. Default 0: as the interior was built.")] float turn = 0, + [Description("Half the side of the box inside which the interior is loaded, metres. Default 35.")] float loadHalf = 35, + [Description("Half the side of the wider box outside which it is let go. Default 60.")] float unloadHalf = 60, + [Description("Half the height of both boxes. Default 23.")] float halfHeight = 23, + [Description("Write the change to the working copies. Default false: report only.")] bool apply = false) + { + try + { + ShopPlaceInfo? place = null; + IReadOnlyList archives = []; + string? refused = await ui.RunAsync(() => editor.ShopPlaceAdd(shop, point, turn, loadHalf, unloadHalf, halfHeight, apply, out place, out archives)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, applied = apply, place, archivesToBuild = archives }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "shop_place_delete")] + [Description("Take a place of an interior that was ADDED (shop_place_add - shop_places marks it 'Added') out again: its marker in the interior's archive, its pair of volumes in city_univers.sds and its rows in cityshops.bin. A place the game ships with is refused. By default it only REPORTS; apply=true writes the working copies - then archive_build BOTH archives named in the answer. Refused while either archive is open in an editor.")] + public static async Task ShopPlaceDelete( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The place's marker, as shop_places lists it, e.g. 'GUNSHOP_translocator_14'.")] string marker, + [Description("Write the change to the working copies. Default false: report only.")] bool apply = false) + { + try + { + ShopPlaceInfo? place = null; + IReadOnlyList archives = []; + string? refused = await ui.RunAsync(() => editor.ShopPlaceDelete(marker, apply, out place, out archives)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, applied = apply, place, archivesToBuild = archives }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "shop_create")] + [Description("Make an INTERIOR OF YOUR OWN the way the game keeps its shops and flats: a copy of one of the table's interiors under a new name, standing at 'point'. The copy is an archive of its own - shops\\.sds, made from the working copy of 'like', with its marker frames named after the new interior - and gets a row of its own in cityshops.bin, a marker for its first place and a pair of box volumes round it in city_univers.sds. What stands inside is then changed like any other archive (open it, delete, import, push from Blender); more places are added with shop_place_add. Pick a small interior to copy - 'elgreco' is one room with one sector. By default it only REPORTS; apply=true writes the working copies and packs the NEW archive (no file of the game is overwritten) - then archive_build city_univers.sds, named in the answer. Refused while city_univers is open in an editor. Take it out of the table again with shop_delete.")] + public static async Task ShopCreate( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The new interior's name: 3 to 31 lower-case letters, digits and '_', starting with a letter. It is its archive's name too.")] string name, + [Description("The interior to copy, as shop_places lists it, e.g. 'elgreco'.")] string like, + [Description("Where the interior's own origin goes, world [x, y, z] - where the copied interior's marker stood against its room.")] float[] point, + [Description("Turn about the vertical, degrees, counter-clockwise seen from above. Default 0.")] float turn = 0, + [Description("Half the side of the box inside which the interior is loaded, metres. Default 35.")] float loadHalf = 35, + [Description("Half the side of the wider box outside which it is let go. Default 60.")] float unloadHalf = 60, + [Description("Half the height of both boxes. Default 23.")] float halfHeight = 23, + [Description("Write the change. Default false: report only.")] bool apply = false) + { + try + { + ShopPlaceInfo? place = null; + IReadOnlyList archives = []; + IReadOnlyList notes = []; + string? refused = await ui.RunAsync(() => editor.ShopCreate(name, like, point, turn, loadHalf, unloadHalf, halfHeight, apply, out place, out archives, out notes)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, applied = apply, place, archivesToBuild = archives, notes }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "shop_delete")] + [Description("Take an interior that was ADDED (shop_create) out of cityshops.bin again: its row, its area rows and their volumes in city_univers.sds - after which the game never asks for it. Its archive under shops\\ and its working copy are left where they are. An interior the game ships with is refused. By default it only REPORTS; apply=true writes the working copy - then archive_build city_univers.sds. Refused while city_univers is open in an editor.")] + public static async Task ShopDelete( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The interior, as shop_places lists it.")] string name, + [Description("Write the change to the working copy. Default false: report only.")] bool apply = false) + { + try + { + ShopInfo? shop = null; + IReadOnlyList archives = []; + string? refused = await ui.RunAsync(() => editor.ShopDelete(name, apply, out shop, out archives)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, applied = apply, shop, archivesToBuild = archives }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "archive_materials")] + [Description("What an archive needs REGISTERED to draw as it was made: the materials its meshes are drawn with, each in full, and which of them the game does not have. A mesh names its materials by hash; the definitions are not in the archive but in the game's material libraries (edit\\materials\\*.mtl) - so an archive handed to someone else (a multiplayer server, another modder) is missing every material that was added or changed here. The archive FILE is read as it stands in pc\\sds (build it first) and the libraries from disk. Each material comes with its 'origin' against the game as it ships - 'added', 'changed' (the game's own, defined differently here), 'shipped', or 'unknown' when there is no list for this edition (the list covers Mafia II, library version 57; not the Definitive Edition) - and with name, hash, flags, shader id and hash, the library's nameless fields, every sampler (texture, whether that texture is INSIDE the archive, sampler states) and every parameter. By default the document holds only the materials that have to travel with the archive; all=true lists every one. 'missing' are hashes the archive names and no library has - those parts draw with no material. 64-bit hashes are hex text, safe for a JavaScript reader. saveTo also writes the document to a file to send along. Nothing of the game is written.")] + public static async Task ArchiveMaterials( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The archive: a full path to an .sds, or a path under pc\\sds such as 'cars/shubert_38_custom.sds' or 'city/southport'.")] string archive, + [Description("List every material the archive uses, the game's own too. Default false: only added and changed ones.")] bool all = false, + [Description("Full path of a .json file to also write the document to. Default: none.")] string? saveTo = null, + [Description("Full path of an .mtl file to also write the added and changed materials to, as a material library of their own - the file a multiplayer host loads beside the game's libraries (Mafia II Online: stream/materials/.mtl, names of a-z 0-9 _ only and not default*). An archive that adds nothing writes no file (LibraryMaterials 0). Never inside the game's edit\\materials. Default: none.")] string? libraryTo = null) + { + try + { + ArchiveMaterialsInfo? result = null; + string? refused = await ui.RunAsync(() => editor.ArchiveMaterials(archive, all, saveTo, libraryTo, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } } diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index b0431b4..4dd2909 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -25,7 +25,7 @@ public static bool TryRun(string[] args) return true; // StreamMap catalog (timeline of scripts/cutscenes). case "--probe-streammap": - WorldProbes.RunStreamMapProbe(); + WorldProbes.RunStreamMapProbe(args.Skip(1).ToArray()); return true; // Location catalog (Location × Season). case "--probe-map": @@ -999,6 +999,59 @@ public static bool TryRun(string[] args) // What the PREFAB containers hold, game-wide: which init-data types exist, how many, how big, and // which folders carry them — plus one car's container in full. Optional arg = the car. // Output: %TEMP%\illusion_prefabs.txt + // What a car's PREFAB says about its lights, for several cars side by side (default: the police + // Smith, the civilian one and the Shubert 38). Output: %TEMP%\illusion_car_lights.txt + case "--probe-car-lights": + CarLightProbes.Run(args.Skip(1).ToArray()); + return true; + // What the game's light frames and sectors hold, with the lights split by where they stand (in a + // room-sized sector, a district-sized one, none). Optional args = archives under pc\sds without + // the extension, e.g. city\italy shops\gunshop. Output: %TEMP%\illusion_world_lights.txt + // Frames of one archive by name part: kind, parents, place. Output: %TEMP%\illusion_frames.txt + case "--probe-frames": + WorldLightProbes.Frames(args.Skip(1).ToArray()); + return true; + // The places of the game's interiors: cityshops.bin through reader and writer, a place added for the gun + // shop in memory and taken out again. Nothing is written. Output: %TEMP%\illusion_shop_places.txt + // The materials an archive is drawn with and the list of the game's own: told apart, a stock car + // needing nothing, the document safe for a JavaScript reader. Optional args = archives under + // pc\sds to list as well. Output: %TEMP%\illusion_archive_materials.txt + case "--probe-archive-materials": + ArchiveMaterialProbes.Run(args.Skip(1).ToArray()); + return true; + // Makes the embedded list of the game's own materials (Illusion.Assets\Materials\ShippedMaterials.bin) + // from the libraries of an UNTOUCHED install: out.bin, then the .mtl files in lookup order. + case "--make-shipped-materials": + ArchiveMaterialProbes.MakeShipped(args.Skip(1).ToArray()); + return true; + case "--probe-shop-places": + ShopPlaceProbes.Run(); + return true; + // What an interior opens showing: the scenes of the shipped interiors are not sorted as proxy or + // snow by the name-table flags of their holders (a district's are), and the room is drawn on the + // stage. Optional args = interiors under pc\sds without the extension. + // Output: %TEMP%\illusion_interior_scenes.txt + case "--probe-interior-scenes": + InteriorSceneProbes.Run(args.Skip(1).ToArray()); + return true; + case "--probe-world-lights": + WorldLightProbes.Run(args.Skip(1).ToArray()); + return true; + // Everything a car's rig stores per bone, for one car or two side by side (default: the civilian + // Smith against the police one - the same car with two bones added). + // Output: %TEMP%\illusion_car_skeleton.txt + case "--probe-car-skeleton": + CarSkeletonProbes.Run(args.Skip(1).ToArray()); + return true; + case "--experiment-car-bone-add": + CarLightProbes.AddBones(args.Skip(1).ToArray()); + return true; + case "--experiment-car-light-add": + CarLightProbes.AddLight(args.Skip(1).ToArray()); + return true; + case "--experiment-car-light-kind": + CarLightProbes.SetKind(args.Skip(1).ToArray()); + return true; case "--probe-prefabs": PrefabProbes.RunPrefabsProbe(args.Length >= 2 ? args[1] : "shubert_38"); return true; diff --git a/src/Illusion/Diagnostics/Probes/ArchiveMaterialProbes.cs b/src/Illusion/Diagnostics/Probes/ArchiveMaterialProbes.cs new file mode 100644 index 0000000..cfadd77 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/ArchiveMaterialProbes.cs @@ -0,0 +1,228 @@ +using System.Globalization; +using System.IO; +using System.Text; +using System.Text.Json.Nodes; +using Illusion.Assets; +using Illusion.Assets.Materials; +using Illusion.Formats.Materials; +using Illusion.Formats.Materials.Versions; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// +/// The materials of an archive () and the list of the game's own +/// () against the real install: the list is there and tells a material of the +/// game from a changed one and an added one; a car the game ships needs nothing registered; a car of ours +/// needs exactly the materials made for it, and carries their textures; the document written for whoever takes +/// the archive holds no number a JavaScript reader would round. Nothing is written. +/// Output: %TEMP%\illusion_archive_materials.txt. Arguments: archives under pc\sds to list as well. +/// +internal static class ArchiveMaterialProbes +{ + /// + /// Makes the embedded list of the game's own materials from the libraries of an UNTOUCHED install. + /// Arguments: the file to write, then the libraries in the order the game looks a hash up in. + /// + internal static void MakeShipped(string[] args) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_shipped_materials.txt"); + var sb = new StringBuilder(); + try + { + if (args.Length < 2) { sb.AppendLine("usage: out.bin library.mtl ..."); return; } + var all = new List(); + foreach (string path in args.Skip(1)) + { + var library = new MaterialLibrary(MaterialVersion.V_57); + library.ReadMatFile(path); + sb.AppendLine($"{Path.GetFileName(path)}: version {(int)library.Version}, {library.Materials.Count} materials"); + all.AddRange(library.Materials.Values); + } + byte[] file = ShippedMaterials.Build(all); + File.WriteAllBytes(args[0], file); + sb.AppendLine($"written {args[0]}: {file.Length} bytes, {(file.Length - 12) / 16} materials"); + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception - " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + internal static void Run(string[] extra) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_archive_materials.txt"); + var sb = new StringBuilder(); + int pass = 0, fail = 0; + void Check(string label, bool ok, string detail = "") + { + if (ok) pass++; else fail++; + sb.AppendLine($"[{(ok ? "PASS" : "FAIL")}] {label}{(detail.Length > 0 ? " - " + detail : "")}"); + } + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + + // ---- the list, and the libraries as they stand on disk + Check("the list of the game's own materials is there", ShippedMaterials.Count > 5000, $"{ShippedMaterials.Count} materials"); + IReadOnlyList libraries = ArchiveMaterials.LoadLibraries(MafiaEnvironment.GameRoot); + bool classic = libraries.All(l => l.Version == MaterialVersion.V_57); + sb.AppendLine("libraries: " + string.Join(", ", libraries.Select(l => $"{Path.GetFileName(l.Name)} v{(int)l.Version} ({l.Materials.Count})"))); + if (!classic) + { + sb.AppendLine("the libraries are not the classic game's - the list does not speak for them, the rest is skipped"); + return; + } + var seen = new HashSet(); + List every = [.. libraries.SelectMany(l => l.Materials.Values).Where(m => seen.Add(m.GetMaterialHash()))]; + ILookup byOrigin = every.ToLookup(ShippedMaterials.OriginOf); + sb.AppendLine($"on disk: {byOrigin[MaterialOrigin.Shipped].Count()} the game's own, {byOrigin[MaterialOrigin.Changed].Count()} changed, {byOrigin[MaterialOrigin.Added].Count()} added"); + sb.AppendLine(" changed: " + Names(byOrigin[MaterialOrigin.Changed])); + sb.AppendLine(" added: " + Names(byOrigin[MaterialOrigin.Added])); + Check("nearly all of the library is still the game's own", byOrigin[MaterialOrigin.Shipped].Count() > every.Count * 0.9, + "a library read and written many times must not look changed"); + + // ---- one of the game's own materials, taken apart + if (libraries[0].LookupMaterialByName("ShuC_body") is Material_v57 body) + { + Check("a material of the game is told as the game's own", ShippedMaterials.OriginOf(body) == MaterialOrigin.Shipped); + Check("a copy of it digests alike", ShippedMaterials.Digest(new Material_v57(body)) == ShippedMaterials.Digest(body)); + var a = new Material_v57(body) { Unk0 = (byte)(body.Unk0 ^ 0x80) }; + Check("a changed nameless field makes it 'changed'", ShippedMaterials.OriginOf(a) == MaterialOrigin.Changed); + var b = new Material_v57(body); + b.Samplers[0].TextureName.Set("another_picture.dds"); + Check("another texture makes it 'changed'", ShippedMaterials.OriginOf(b) == MaterialOrigin.Changed); + var c = new Material_v57(body); + bool hasNumber = c.Parameters.Count > 0 && c.Parameters[0].Paramaters.Length > 0; + if (hasNumber) c.Parameters[0].Paramaters[0] += 0.5f; + Check("another parameter value makes it 'changed'", hasNumber && ShippedMaterials.OriginOf(c) == MaterialOrigin.Changed); + var d = new Material_v57(body) { Flags = body.Flags ^ MaterialFlags.Alpha }; + Check("another flag makes it 'changed'", ShippedMaterials.OriginOf(d) == MaterialOrigin.Changed); + var e = new Material_v57(body); + e.SetName("ShuC_body_of_ours"); + Check("under a name the game does not have it is 'added'", ShippedMaterials.OriginOf(e) == MaterialOrigin.Added); + // a copy has arrays of its own: the parameter values and sampler states used to be shared + Check("changing the copies left the game's material as it was", ShippedMaterials.OriginOf(body) == MaterialOrigin.Shipped); + var f = new Material_v57(body); + f.Samplers[0].SamplerStates[0] ^= 1; + f.Samplers[0].UnkSet0[0] += 1; + Check("and so did changing a copy's sampler states and sets", ShippedMaterials.OriginOf(f) == MaterialOrigin.Changed + && ShippedMaterials.OriginOf(body) == MaterialOrigin.Shipped); + } + else + { + Check("the library has ShuC_body to take apart", false); + } + + // ---- a car the game ships + var stock = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", "smith_200_p_pha.sds")); + if (stock.Exists) + { + ArchiveMaterials.Report r = ArchiveMaterials.Read(stock, libraries); + Check("a car of the game needs nothing registered", r.OriginKnown && r.Materials.Count >= 4 && !r.NotShipped.Any() && r.Missing.Count == 0, + $"{r.Materials.Count} materials, not the game's own: {Names(r.NotShipped)}, missing {r.Missing.Count}"); + Check("its textures are found inside it", r.Materials.SelectMany(m => m.Samplers).Count(s => s.InArchive) >= 3 && r.Textures.Count >= 3, + $"{r.Textures.Count} textures in the archive"); + JsonObject brief = ArchiveMaterials.ToJson(r, all: false), full = ArchiveMaterials.ToJson(r, all: true); + Check("the document lists only what has to travel, or everything when asked", + ((JsonArray)brief["materials"]!).Count == 0 && ((JsonArray)full["materials"]!).Count == r.Materials.Count); + Check("no number in the document is too long for a JavaScript reader", LongestNumber(full) <= 9007199254740992d, + LongestNumber(full).ToString("R", CultureInfo.InvariantCulture)); + ArchiveMaterials.Material first = r.Materials[0]; + JsonObject written = (JsonObject)((JsonArray)full["materials"]!)[0]!; + Check("a hash is written as hex text and reads back", written["hash"]!.GetValue() is { Length: 18 } hex + && ulong.Parse(hex.AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) == first.Hash + && ulong.Parse(written["shaderId"]!.GetValue().AsSpan(2), NumberStyles.HexNumber, CultureInfo.InvariantCulture) == first.ShaderId); + // without the library that has them, they are missing - not silently left out + ArchiveMaterials.Report bare = ArchiveMaterials.Read(stock, [.. libraries.Skip(1)]); + Check("a material no library has is reported as missing", bare.Missing.Count > 0 && bare.Missing.Count + bare.Materials.Count == r.Materials.Count, + $"{bare.Missing.Count} missing without {Path.GetFileName(libraries[0].Name)}"); + string none = Path.Combine(Path.GetTempPath(), "illusion_probe_stock_car.mtl"); + File.Delete(none); + Check("and it writes no library of its own", ArchiveMaterials.SaveLibrary(r, libraries, none) == 0 && !File.Exists(none)); + } + else + { + sb.AppendLine("no smith_200_p_pha.sds - the stock car checks are skipped"); + } + + // ---- a car of ours, when this install has it + var ours = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", "shubert_38_custom.sds")); + if (ours.Exists) + { + ArchiveMaterials.Report r = ArchiveMaterials.Read(ours, libraries); + Describe(sb, r); + Check("the custom Shubert's own materials are told as added", r.NotShipped.Any() && r.NotShipped.All(m => m.Origin == MaterialOrigin.Added) + && r.Missing.Count == 0, Names(r.NotShipped)); + Check("each of them has its texture inside the archive", r.NotShipped.All(m => m.Samplers.Count > 0 && m.Samplers.All(s => s.Texture.Length == 0 || s.InArchive))); + Check("the game's own materials it uses stay the game's own", r.Materials.Any(m => m.Name == "ShuC_body" && m.Origin == MaterialOrigin.Shipped)); + // ---- what it adds, as a library of its own + string own = Path.Combine(Path.GetTempPath(), "illusion_probe_custom_car.mtl"); + File.Delete(own); + int held = ArchiveMaterials.SaveLibrary(r, libraries, own); + var back = new MaterialLibrary(MaterialVersion.V_57); + if (File.Exists(own)) back.ReadMatFile(own); + Check("its added materials are written as a library that holds them and nothing else", + held == r.NotShipped.Count() && back.Materials.Count == held && r.NotShipped.All(m => back.Materials.ContainsKey(m.Hash)), + $"{held} written, {back.Materials.Count} read back, {new FileInfo(own).Length} bytes"); + Check("each reads back as the material the game's library has", + held > 0 && back.Version == libraries[0].Version && back.Materials.All(pair => libraries.Select(l => l.LookupMaterialByHash(pair.Key)) + .FirstOrDefault(m => m != null) is { } original && ShippedMaterials.Digest(pair.Value) == ShippedMaterials.Digest(original))); + File.Delete(own); + } + + foreach (string name in extra) + { + var archive = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", name.EndsWith(".sds", StringComparison.OrdinalIgnoreCase) ? name : name + ".sds")); + if (!archive.Exists) { sb.AppendLine($"no such archive: {name}"); continue; } + Describe(sb, ArchiveMaterials.Read(archive, libraries)); + } + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception - " + ex); + fail++; + } + finally + { + sb.Insert(0, $"ARCHIVE MATERIALS PROBE: {pass} passed, {fail} failed{Environment.NewLine}"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static string Names(IEnumerable materials) + { + List names = [.. materials.Select(m => m.GetMaterialName()).OrderBy(n => n, StringComparer.OrdinalIgnoreCase)]; + return names.Count == 0 ? "none" : $"{names.Count}: " + string.Join(", ", names.Take(60)) + (names.Count > 60 ? ", ..." : ""); + } + + private static string Names(IEnumerable materials) + { + List names = [.. materials.Select(m => m.Name)]; + return names.Count == 0 ? "none" : string.Join(", ", names); + } + + private static void Describe(StringBuilder sb, ArchiveMaterials.Report r) + { + sb.AppendLine($"{r.Archive}: {r.Materials.Count} materials, {r.Missing.Count} missing, {r.Textures.Count} textures inside"); + foreach (ArchiveMaterials.Material m in r.Materials) + { + sb.AppendLine($" {m.Origin,-8} {m.Name,-28} 0x{m.Hash:x16} flags 0x{m.Flags:x8} shader 0x{m.ShaderId:x16}/0x{m.ShaderHash:x8} unk0 {m.Unk0} parts {m.Parts} tris {m.Triangles} " + + string.Join(", ", m.Samplers.Select(s => $"{s.Id}={s.Texture}{(s.InArchive ? " (inside)" : "")}"))); + } + foreach (ArchiveMaterials.Missing m in r.Missing) sb.AppendLine($" MISSING 0x{m.Hash:x16} {m.Name} parts {m.Parts} tris {m.Triangles}"); + } + + // The largest magnitude among the document's numbers. + private static double LongestNumber(JsonNode? node) => node switch + { + JsonObject o => o.Select(p => LongestNumber(p.Value)).DefaultIfEmpty(0).Max(), + JsonArray a => a.Select(LongestNumber).DefaultIfEmpty(0).Max(), + JsonValue v when v.GetValueKind() == System.Text.Json.JsonValueKind.Number => Math.Abs(double.Parse(v.ToJsonString(), CultureInfo.InvariantCulture)), + _ => 0, + }; +} diff --git a/src/Illusion/Diagnostics/Probes/CarLightProbes.cs b/src/Illusion/Diagnostics/Probes/CarLightProbes.cs new file mode 100644 index 0000000..7952b15 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/CarLightProbes.cs @@ -0,0 +1,225 @@ +using System.Globalization; +using System.IO; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Archive; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Hashing; +using Illusion.Formats.Prefab; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// +/// What a car's PREFAB says about its lights, side by side for several cars: the frames named as headlight, +/// backlight and toplight, the light sources' places, and every entry of the lights list - which bone is lit, +/// the numbers that tell one kind of light from another, how it glows and pulses. Read from the working +/// copies; nothing is written. It exists to learn what a police car has that a civilian one has not. +/// Output: %TEMP%\illusion_car_lights.txt +/// +internal static class CarLightProbes +{ + /// + /// AN EXPERIMENT, not a tool: turns the light of one bone of a car into another kind, in the PREFAB of the + /// car's working copy. Arguments: car, bone name, kind, the last number of the entry, light model name. + /// The file is written in place - keep a copy first. Output: %TEMP%\illusion_car_light_set.txt + /// + internal static void SetKind(string[] a) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_light_set.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (a.Length < 5) { sb.AppendLine("usage: car bone kind unk12 model"); return; } + var car = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", a[0] + ".sds")); + string extracted = MafiaEnvironment.ExtractedDir(car); + string file = Path.Combine(extracted, SdsManifest.Load(extracted).GetFiles("PREFAB")[0]); + byte[] before = File.ReadAllBytes(file); + PrefabFile prefab = PrefabFile.Load(file); + sb.AppendLine($"{file}: {before.Length} bytes; written back unchanged it is the same file: {prefab.ToBytes().AsSpan().SequenceEqual(before)}"); + ulong bone = Fnv64.Hash(a[1]); + Illusion.Formats.Native.Model.PrefabLightInitW? light = prefab.Wire.Prefabs.Where(p => p.CarInit.Count > 0) + .SelectMany(p => p.CarInit[0].ShaderEffects).SelectMany(e => e.Lights).FirstOrDefault(l => l.FrameName == bone); + if (light == null) { sb.AppendLine($"no light on bone '{a[1]}'"); return; } + sb.AppendLine($"'{a[1]}' was kind 0x{light.Unk3:X}, last number {light.Unk12}, model 0x{light.LightModelHash:X16}"); + light.Unk3 = uint.Parse(a[2], CultureInfo.InvariantCulture); + light.Unk12 = uint.Parse(a[3], CultureInfo.InvariantCulture); + light.LightModelHash = Fnv64.Hash(a[4]); + byte[] after = prefab.ToBytes(); + File.WriteAllBytes(file, after); + CarPrefab.Light? read = PrefabFile.Load(file).Car?.Lights.FirstOrDefault(l => l.Frame == bone); + sb.AppendLine($"written: {after.Length} bytes; reads back as kind 0x{read?.Unk3:X}, last number {read?.Unk12}, model 0x{read?.LightModel:X16} ({a[4]})"); + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + /// + /// AN EXPERIMENT, not a tool: adds a bone to a car's rig in its working copy () + /// and writes the scene. Arguments: car, then any number of "name parent x y z". Keep a copy first, and + /// reopen the car in the editor afterwards. Output: %TEMP%\illusion_car_bone_add.txt + /// + internal static void AddBones(string[] a) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_bone_add.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (a.Length < 6 || (a.Length - 1) % 5 != 0) { sb.AppendLine("usage: car (name parent x y z)..."); return; } + var car = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", a[0] + ".sds")); + string extracted = MafiaEnvironment.ExtractedDir(car); + Illusion.Formats.Frames.FrameResource? frame = SdsMeshLoader.OpenScene(extracted).FrameResource; + FrameObjectModel? model = frame?.FrameObjects.Values.OfType().OrderByDescending(m => m.GetSkeletonObject().BoneNames.Length).FirstOrDefault(); + if (frame == null || model == null) { sb.AppendLine("no skinned model"); return; } + sb.AppendLine($"{a[0]}: {model.GetSkeletonObject().BoneNames.Length} bones before"); + for (int i = 1; i + 4 < a.Length; i += 5) + { + var at = new System.Numerics.Vector3(float.Parse(a[i + 2], CultureInfo.InvariantCulture), float.Parse(a[i + 3], CultureInfo.InvariantCulture), float.Parse(a[i + 4], CultureInfo.InvariantCulture)); + string? refused = Illusion.Assets.Frames.RigBones.Insert(model, a[i], a[i + 1], at, new System.Numerics.Vector3(0.06f, 0.10f, 0.07f), out int index); + sb.AppendLine(refused == null ? $" '{a[i]}' added under '{a[i + 1]}' at #{index}, standing at {at}" : $" '{a[i]}' REFUSED: {refused}"); + if (refused != null) return; // nothing is written when any bone is refused + } + string written = SdsWriter.SaveFrameResource(frame, car); + FrameObjectModel? read = SdsMeshLoader.OpenScene(extracted).FrameResource?.FrameObjects.Values.OfType() + .OrderByDescending(m => m.GetSkeletonObject().BoneNames.Length).FirstOrDefault(); + sb.AppendLine($"written {written}; read back: {read?.GetSkeletonObject().BoneNames.Length} bones, " + + string.Join(", ", (read?.GetSkeletonObject().BoneNames ?? []).Select((n, i) => (n, i)).Where(x => a.Contains(x.n.String)).Select(x => $"{x.n.String} #{x.i}"))); + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + /// + /// AN EXPERIMENT, not a tool: adds a light to a car's PREFAB in its working copy, written like the light + /// of another bone. Arguments: car, bone, kind, side (8 left, 0 right), the entry's last number, light model + /// name, check bone, the bone whose entry is the pattern. Output: %TEMP%\illusion_car_light_set.txt + /// + internal static void AddLight(string[] a) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_light_set.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (a.Length < 8) { sb.AppendLine("usage: car bone kind side unk12 model checkBone likeBone"); return; } + var car = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", a[0] + ".sds")); + string extracted = MafiaEnvironment.ExtractedDir(car); + string file = Path.Combine(extracted, SdsManifest.Load(extracted).GetFiles("PREFAB")[0]); + PrefabFile prefab = PrefabFile.Load(file); + ulong like = Fnv64.Hash(a[7]), bone = Fnv64.Hash(a[1]); + List? list = prefab.Wire.Prefabs.Where(p => p.CarInit.Count > 0) + .SelectMany(p => p.CarInit[0].ShaderEffects).Select(e => e.Lights).FirstOrDefault(l => l.Any(x => x.FrameName == like)); + Illusion.Formats.Native.Model.PrefabLightInitW? pattern = list?.FirstOrDefault(x => x.FrameName == like); + if (list == null || pattern == null) { sb.AppendLine($"no light on bone '{a[7]}' to write it like"); return; } + if (list.Any(x => x.FrameName == bone)) { sb.AppendLine($"'{a[1]}' has a light already"); return; } + list.Add(new Illusion.Formats.Native.Model.PrefabLightInitW + { + FrameName = bone, + Unk1 = pattern.Unk1, + Unk2 = pattern.Unk2, + Unk3 = uint.Parse(a[2], CultureInfo.InvariantCulture), + Unk4 = uint.Parse(a[3], CultureInfo.InvariantCulture), + EmissivePower = pattern.EmissivePower, + EmissiveMiddle = pattern.EmissiveMiddle, + EmissiveSpeed0 = pattern.EmissiveSpeed0, + EmissiveSpeed1 = pattern.EmissiveSpeed1, + CheckBoneName = [Fnv64.Hash(a[6])], + LightModelHash = Fnv64.Hash(a[5]), + ParticleBreakId = pattern.ParticleBreakId, + Unk12 = uint.Parse(a[4], CultureInfo.InvariantCulture), + }); + byte[] after = prefab.ToBytes(); + File.WriteAllBytes(file, after); + sb.AppendLine($"'{a[1]}' added as light {list.Count} of its list; the file is {after.Length} bytes and reads back with {PrefabFile.Load(file).Car?.Lights.Count} lights"); + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + internal static void Run(string[] cars) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_lights.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (cars.Length == 0) cars = ["smith_200_p_pha", "smith_200_pha", "shubert_38"]; + string F(float v) => v.ToString("0.###", CultureInfo.InvariantCulture); + + foreach (string name in cars) + { + var car = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", name + ".sds")); + sb.AppendLine($"════ {name} ════"); + if (!car.Exists) { sb.AppendLine(" no such archive"); continue; } + string extracted = SdsMeshLoader.EnsureExtracted(car); + + // every frame and bone name of the car, by hash: what the prefab's references are read against + var frames = new Dictionary(); + if (SdsMeshLoader.OpenScene(extracted).FrameResource is { FrameObjects: not null } fr) + { + foreach (object o in fr.FrameObjects.Values) + { + if (o is FrameObjectBase f && f.Name.String is { Length: > 0 } n) frames[f.Name.Hash] = n; + } + foreach (FrameObjectModel m in fr.FrameObjects.Values.OfType()) + { + foreach (HashName bone in m.GetSkeletonObject().BoneNames ?? []) + { + if (bone.String is { Length: > 0 } bn) frames[bone.Hash] = bn; + } + } + } + string Named(ulong hash) => hash == 0 ? "-" : frames.TryGetValue(hash, out string? n) ? n : "0x" + hash.ToString("X16", CultureInfo.InvariantCulture); + + foreach (string file in SdsManifest.Load(extracted).GetFiles("PREFAB")) + { + if (PrefabFile.Load(Path.Combine(extracted, file)).Car is not { } prefab) continue; + sb.AppendLine($" headlight model {Named(prefab.HeadlightModel)}, backlight model {Named(prefab.BacklightModel)}, toplight model {Named(prefab.ToplightModel)}"); + sb.AppendLine($" light sources: {prefab.LightMatrices.Count}"); + foreach ((System.Numerics.Vector3 at, System.Numerics.Vector3 r0, System.Numerics.Vector3 r1, System.Numerics.Vector3 r2) in prefab.LightMatrices) + { + sb.AppendLine($" at ({F(at.X)}, {F(at.Y)}, {F(at.Z)}) axes ({F(r0.X)}, {F(r0.Y)}, {F(r0.Z)}) ({F(r1.X)}, {F(r1.Y)}, {F(r1.Z)}) ({F(r2.X)}, {F(r2.Y)}, {F(r2.Z)})"); + } + sb.AppendLine($" lights: {prefab.Lights.Count}"); + sb.AppendLine($" {"frame",-26} {"unk1",5} {"unk2",5} {"unk3",10} {"unk4",10} {"power",7} {"middle",7} {"speed0",7} {"speed1",7} {"model",-20} {"break",6} {"unk12",6} check bones"); + foreach (CarPrefab.Light l in prefab.Lights) + { + sb.AppendLine($" {Named(l.Frame),-26} {l.Unk1,5} {l.Unk2,5} {"0x" + l.Unk3.ToString("X", CultureInfo.InvariantCulture),10} {"0x" + l.Unk4.ToString("X", CultureInfo.InvariantCulture),10} " + + $"{F(l.EmissivePower),7} {F(l.EmissiveMiddle),7} {F(l.EmissiveSpeed0),7} {F(l.EmissiveSpeed1),7} {Named(l.LightModel),-20} {l.ParticleBreakId,6} {l.Unk12,6} " + + string.Join(", ", l.CheckBones.Select(Named))); + } + } + sb.AppendLine(); + } + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } +} diff --git a/src/Illusion/Diagnostics/Probes/CarSkeletonProbes.cs b/src/Illusion/Diagnostics/Probes/CarSkeletonProbes.cs new file mode 100644 index 0000000..84ba78a --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/CarSkeletonProbes.cs @@ -0,0 +1,221 @@ +using System.Globalization; +using System.IO; +using System.Numerics; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Everything a car's rig stores per bone, for one car or for two side by side: the skeleton's counts and +/// tables, the hierarchy, the blend info and the model's own per-bone arrays. With two cars it says where they +/// differ - which is how the cost of ONE MORE BONE is read off the game's own data: the police Smith is the +/// civilian Smith with two bones added. +/// Reads the working copies; nothing is written. Output: %TEMP%\illusion_car_skeleton.txt +/// +internal static class CarSkeletonProbes +{ + private sealed record Rig(string Name, FrameObjectModel Model, FrameSkeleton Skeleton, FrameSkeletonHierarchy Hierarchy, FrameBlendInfo Blend); + + internal static void Run(string[] cars) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_skeleton.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (cars.Length == 0) cars = ["smith_200_pha", "smith_200_p_pha"]; + var rigs = new List(); + foreach (string name in cars) + { + var car = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", name + ".sds")); + if (!car.Exists) { sb.AppendLine($"{name}: no such archive"); continue; } + FrameObjectModel? model = SdsMeshLoader.OpenScene(SdsMeshLoader.EnsureExtracted(car)).FrameResource?.FrameObjects.Values + .OfType().OrderByDescending(m => m.GetSkeletonObject().BoneNames.Length).FirstOrDefault(); + if (model == null) { sb.AppendLine($"{name}: no skinned model"); continue; } + rigs.Add(new Rig(name, model, model.GetSkeletonObject(), model.GetSkeletonHierarchyObject(), model.GetBlendInfoObject())); + } + foreach (Rig rig in rigs) Dump(sb, rig); + if (rigs.Count == 2) Compare(sb, rigs[0], rigs[1]); + if (rigs.Count == 2) Rebuild(sb, rigs[0], rigs[1]); + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static string F(float v) => v.ToString("0.###", CultureInfo.InvariantCulture); + + private static string M(Matrix4x4 m) => + $"[{F(m.M11)} {F(m.M12)} {F(m.M13)} {F(m.M14)} | {F(m.M21)} {F(m.M22)} {F(m.M23)} {F(m.M24)} | {F(m.M31)} {F(m.M32)} {F(m.M33)} {F(m.M34)} | {F(m.M41)} {F(m.M42)} {F(m.M43)} {F(m.M44)}]"; + + private static string Bytes(byte[]? b, int from = 0) => b == null ? "(none)" : string.Join(" ", b.Skip(from).Select(x => x.ToString(CultureInfo.InvariantCulture))); + + private static void Dump(StringBuilder sb, Rig r) + { + FrameSkeleton s = r.Skeleton; + FrameSkeletonHierarchy h = r.Hierarchy; + int bones = s.BoneNames.Length; + sb.AppendLine($"════ {r.Name} ════"); + sb.AppendLine($" skeleton: {bones} names, NumBones [{string.Join(", ", s.NumBones)}], NumBlendIDs {s.NumBlendIDs}, IDType {s.IDType}, NumUnkCount2 {s.NumUnkCount2}, " + + $"LodRemapIDCount [{string.Join(", ", s.LodRemapIDCount)}]"); + sb.AppendLine($" arrays: JointTransforms {s.JointTransforms.Length}, WorldTransforms {s.WorldTransforms.Length}, BoneLODUsage {s.BoneLODUsage.Length}, " + + $"mapping infos {s.MappingForBlendingInfos.Length}"); + for (int lod = 0; lod < s.MappingForBlendingInfos.Length; lod++) + { + FrameSkeleton.MappingForBlendingInfo map = s.MappingForBlendingInfos[lod]; + sb.AppendLine($" level {lod}: bounds {map.Bounds?.Length ?? 0}, RefToUsageArray {map.RefToUsageArray?.Length ?? 0}, UsageArray {map.UsageArray?.Length ?? 0}"); + sb.AppendLine($" RefToUsageArray: {Bytes(map.RefToUsageArray)}"); + sb.AppendLine($" UsageArray: {Bytes(map.UsageArray)}"); + } + sb.AppendLine($" hierarchy: ParentIndices {h.ParentIndices.Length}, LastChildIndices {h.LastChildIndices.Length}, Unk01 {h.Unk01}, UnkData {h.UnkData.Length}"); + sb.AppendLine($" UnkData: {Bytes(h.UnkData)}"); + FrameBlendInfo b = r.Blend; + sb.AppendLine($" blend info: BoneTransforms {b.BoneTransforms.Length}, levels {b.BoneIndexInfos.Length}"); + for (int lod = 0; lod < b.BoneIndexInfos.Length; lod++) + { + FrameBlendInfo.BoneIndexInfo info = b.BoneIndexInfos[lod]; + sb.AppendLine($" level {lod}: pools [{Bytes(info.BonesPerRemapPool)}], remap ids {info.BoneRemapIDs?.Length ?? 0}, materials {info.SkinnedMaterialInfo?.Length ?? 0}"); + } + sb.AppendLine($" model: RestTransform {r.Model.RestTransform.Length}, HitBoxes {r.Model.HitBoxes?.Length ?? 0}, AttachmentReferences {r.Model.AttachmentReferences.Length}, " + + $"BlendMeshSplits {r.Model.BlendMeshSplits?.Length ?? 0}, UnkFlags 0x{r.Model.UnkFlags:X}"); + sb.AppendLine($" {"#",3} {"name",-26} {"parent",6} {"last",5} {"usage",5} {"valid",5}"); + for (int i = 0; i < bones; i++) + { + byte valid = i < b.BoneTransforms.Length ? b.BoneTransforms[i].IsValid : (byte)255; + sb.AppendLine($" {i,3} {s.BoneNames[i].String,-26} {(i < h.ParentIndices.Length ? h.ParentIndices[i] : 255),6} " + + $"{(i < h.LastChildIndices.Length ? h.LastChildIndices[i] : 255),5} {(i < s.BoneLODUsage.Length ? s.BoneLODUsage[i] : 255),5} {valid,5}"); + } + sb.AppendLine(" the last three bones in full:"); + foreach (int i in Enumerable.Range(0, Math.Min(4, bones)).Concat(Enumerable.Range(Math.Max(4, bones - 3), Math.Min(3, Math.Max(0, bones - 4)))) + .Concat(Enumerable.Range(0, bones).Where(x => (s.BoneNames[x].String ?? "").Contains("beacon", StringComparison.OrdinalIgnoreCase) + || (s.BoneNames[x].String ?? "").StartsWith("lightindicatorF", StringComparison.OrdinalIgnoreCase))).Distinct()) + { + sb.AppendLine($" {i} {s.BoneNames[i].String}"); + if (i < s.JointTransforms.Length) sb.AppendLine($" joint {M(s.JointTransforms[i])}"); + if (i < s.WorldTransforms.Length) sb.AppendLine($" world {M(s.WorldTransforms[i])}"); + if (i < r.Model.RestTransform.Length) sb.AppendLine($" rest {M(r.Model.RestTransform[i])}"); + if (i < b.BoneTransforms.Length) + { + FrameBlendInfo.BoneTransform t = b.BoneTransforms[i]; + sb.AppendLine($" blend {M(t.Transform)} bounds ({F(t.Bounds.Min.X)}, {F(t.Bounds.Min.Y)}, {F(t.Bounds.Min.Z)})..({F(t.Bounds.Max.X)}, {F(t.Bounds.Max.Y)}, {F(t.Bounds.Max.Z)}) valid {t.IsValid}"); + } + for (int lod = 0; lod < s.MappingForBlendingInfos.Length; lod++) + { + Illusion.Formats.Mathematics.BoundingBox[]? bounds = s.MappingForBlendingInfos[lod].Bounds; + if (bounds != null && i < bounds.Length) + { + sb.AppendLine($" level {lod} bounds ({F(bounds[i].Min.X)}, {F(bounds[i].Min.Y)}, {F(bounds[i].Min.Z)})..({F(bounds[i].Max.X)}, {F(bounds[i].Max.Y)}, {F(bounds[i].Max.Z)})"); + } + } + if (r.Model.HitBoxes != null && i < r.Model.HitBoxes.Length) + { + FrameObjectModel.HitBoxInfo hit = r.Model.HitBoxes[i]; + sb.AppendLine($" hit box unk 0x{hit.Unk:X} pos ({hit.Position.S1}, {hit.Position.S2}, {hit.Position.S3}) size ({hit.Size.S1}, {hit.Size.S2}, {hit.Size.S3})"); + } + } + sb.AppendLine(); + } + + // The proof of RigBones.Insert against the game's own data: the bones the second rig has and the first has + // not are added to the FIRST, in memory, and the result must be the second rig's order, parents, last-child + // run, chain and counts. Nothing is written. + private static void Rebuild(StringBuilder sb, Rig a, Rig b) + { + sb.AppendLine($"════ {a.Name} given the bones of {b.Name} ════"); + var have = new HashSet(a.Skeleton.BoneNames.Select(n => n.String ?? "")); + int added = 0; + // parents first: a bone of the second rig is added once its parent is in + bool progress = true; + while (progress) + { + progress = false; + for (int i = 0; i < b.Skeleton.BoneNames.Length; i++) + { + string name = b.Skeleton.BoneNames[i].String ?? ""; + if (have.Contains(name)) continue; + string parent = b.Skeleton.BoneNames[b.Hierarchy.ParentIndices[i]].String ?? ""; + if (!have.Contains(parent)) continue; + Matrix4x4 rest = b.Model.RestTransform[i]; + string? refused = Illusion.Assets.Frames.RigBones.Insert(a.Model, name, parent, new Vector3(rest.M41, rest.M42, rest.M43), new Vector3(0.1f), out int at); + sb.AppendLine(refused == null ? $" added '{name}' under '{parent}' at #{at} (the second rig has it at #{i})" : $" '{name}' REFUSED: {refused}"); + if (refused != null) return; + have.Add(name); + added++; + progress = true; + } + } + FrameSkeleton sa = a.Model.GetSkeletonObject(), sb2 = b.Skeleton; + FrameSkeletonHierarchy ha = a.Model.GetSkeletonHierarchyObject(), hb = b.Hierarchy; + void Say(string what, bool ok) => sb.AppendLine($" [{(ok ? "PASS" : "FAIL")}] {what}"); + Say($"{added} bone(s) added", added > 0); + Say("the bones stand in the same order", sa.BoneNames.Select(n => n.String).SequenceEqual(sb2.BoneNames.Select(n => n.String))); + Say("the parents are the same", ha.ParentIndices.AsSpan().SequenceEqual(hb.ParentIndices)); + Say("the last-child run is the same", ha.LastChildIndices.AsSpan().SequenceEqual(hb.LastChildIndices)); + Say("the chain is the same", ha.UnkData.AsSpan().SequenceEqual(hb.UnkData)); + Say("the counts are the same", sa.NumBones.SequenceEqual(sb2.NumBones) && sa.NumUnkCount2 == sb2.NumUnkCount2); + Say("every table is as long as in the second rig", sa.JointTransforms.Length == sb2.JointTransforms.Length && sa.WorldTransforms.Length == sb2.WorldTransforms.Length + && a.Model.RestTransform.Length == b.Model.RestTransform.Length + && a.Model.GetBlendInfoObject().BoneTransforms.Length == b.Blend.BoneTransforms.Length + && sa.MappingForBlendingInfos.Zip(sb2.MappingForBlendingInfos).All(m => m.First.Bounds.Length == m.Second.Bounds.Length && m.First.RefToUsageArray.Length == m.Second.RefToUsageArray.Length)); + int same = 0; + for (int i = 0; i < Math.Min(sa.JointTransforms.Length, sb2.JointTransforms.Length); i++) + { + if (Near(sa.JointTransforms[i], sb2.JointTransforms[i]) && Near(sa.WorldTransforms[i], sb2.WorldTransforms[i]) && Near(a.Model.RestTransform[i], b.Model.RestTransform[i])) same++; + } + sb.AppendLine($" {same} of {sb2.JointTransforms.Length} bones have the second rig's joint, world and rest matrices (the two cars are not the same car bone for bone)"); + var hungA = a.Model.AttachmentReferences.Select(r => sa.BoneNames[r.JointIndex].String).ToList(); + var hungB = b.Model.AttachmentReferences.Select(r => sb2.BoneNames[r.JointIndex].String).ToList(); + Say("the frames hung on bones still hang on the bones of the same names", hungA.SequenceEqual(hungB)); + sb.AppendLine(); + } + + private static bool Near(Matrix4x4 x, Matrix4x4 y) + { + Matrix4x4 d = x - y; + return MathF.Abs(d.M41) < 0.002f && MathF.Abs(d.M42) < 0.002f && MathF.Abs(d.M43) < 0.002f && MathF.Abs(d.M11) < 0.002f && MathF.Abs(d.M22) < 0.002f && MathF.Abs(d.M33) < 0.002f; + } + + // Where two rigs differ once the bones they share are lined up by NAME: the first must be the smaller. + private static void Compare(StringBuilder sb, Rig a, Rig b) + { + sb.AppendLine($"════ {a.Name} against {b.Name} ════"); + var index = new Dictionary(); + for (int i = 0; i < b.Skeleton.BoneNames.Length; i++) index[b.Skeleton.BoneNames[i].String ?? ""] = i; + int same = 0, moved = 0; + var differ = new List(); + for (int i = 0; i < a.Skeleton.BoneNames.Length; i++) + { + string name = a.Skeleton.BoneNames[i].String ?? ""; + if (!index.TryGetValue(name, out int j)) { differ.Add($"{name}: only in {a.Name}"); continue; } + if (i != j) moved++; + var what = new List(); + static string Parent(Rig r, int bone) => + r.Hierarchy.ParentIndices[bone] is var parent && parent < r.Skeleton.BoneNames.Length ? r.Skeleton.BoneNames[parent].String ?? "" : $"({parent})"; + string pa = Parent(a, i), pb = Parent(b, j); + if (pa != pb) what.Add($"parent {pa} / {pb}"); + int la = a.Hierarchy.LastChildIndices[i], lb = b.Hierarchy.LastChildIndices[j]; + if (la - i != lb - j) what.Add($"subtree {la - i} / {lb - j} bones long"); + if (a.Skeleton.BoneLODUsage[i] != b.Skeleton.BoneLODUsage[j]) what.Add($"usage {a.Skeleton.BoneLODUsage[i]} / {b.Skeleton.BoneLODUsage[j]}"); + if (a.Skeleton.JointTransforms[i] != b.Skeleton.JointTransforms[j]) what.Add("joint transform"); + if (a.Skeleton.WorldTransforms[i] != b.Skeleton.WorldTransforms[j]) what.Add("world transform"); + if (a.Model.RestTransform[i] != b.Model.RestTransform[j]) what.Add("rest transform"); + if (what.Count == 0) same++; else differ.Add($"{name}: " + string.Join("; ", what)); + } + sb.AppendLine($" {same} shared bones are the same in every per-bone table; {moved} stand at another index; {differ.Count} differ:"); + foreach (string line in differ.Take(40)) sb.AppendLine(" " + line); + sb.AppendLine(" only in " + b.Name + ": " + string.Join(", ", + b.Skeleton.BoneNames.Select((n, i) => (Name: n.String ?? "", i)).Where(x => !a.Skeleton.BoneNames.Any(n => n.String == x.Name)).Select(x => $"{x.Name} (#{x.i})"))); + sb.AppendLine($" hierarchy UnkData equal: {a.Hierarchy.UnkData.AsSpan().SequenceEqual(b.Hierarchy.UnkData)} ({a.Hierarchy.UnkData.Length} / {b.Hierarchy.UnkData.Length} bytes), Unk01 {a.Hierarchy.Unk01} / {b.Hierarchy.Unk01}"); + sb.AppendLine($" NumUnkCount2 {a.Skeleton.NumUnkCount2} / {b.Skeleton.NumUnkCount2}; NumBlendIDs {a.Skeleton.NumBlendIDs} / {b.Skeleton.NumBlendIDs}"); + } +} diff --git a/src/Illusion/Diagnostics/Probes/InteriorSceneProbes.cs b/src/Illusion/Diagnostics/Probes/InteriorSceneProbes.cs new file mode 100644 index 0000000..c2acbd7 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/InteriorSceneProbes.cs @@ -0,0 +1,199 @@ +using System.IO; +using System.Numerics; +using System.Text; +using System.Windows; +using System.Windows.Threading; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Domain; +using Illusion.Scene; +using Illusion.Viewport; + +namespace Illusion.Diagnostics.Probes; + +/// +/// What an interior opens showing. +/// +/// The map hides two kinds of scene by default — the proxies a district carries of its neighbours and its +/// winter geometry — and tells them by the flags their objects have in the frame name table. An interior's +/// rooms hang under holders with the same bits set (the game moves those holders to the shop's place), so an +/// interior read by the district's rule opened without its walls, floor and ceiling: drawn by the game, +/// counted by the editor, and not in the viewport. This probe holds both ends: the shipped interiors open +/// whole, and a district still has its proxy scenes hidden. +/// +/// +internal static class InteriorSceneProbes +{ + // Args: interiors under pc\sds without the extension (default: shops\elgreco shops\gunshop). + // Output: %TEMP%\illusion_interior_scenes.txt (+ .png, the first interior's room seen from inside on the stage) + internal static void Run(string[] args) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_interior_scenes.txt"); + var sb = new StringBuilder(); + int pass = 0, fail = 0; + void Check(string name, bool ok, string detail = "") + { + if (ok) pass++; else fail++; + sb.AppendLine($"[{(ok ? "PASS" : "FAIL")}] {name}{(detail == "" ? "" : " — " + detail)}"); + } + + Window? window = null; + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + string sdsFolder = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + string[] interiors = args.Length > 0 ? args : [@"shops\elgreco", @"shops\gunshop"]; + + // ── which archives have proxy and snow scenes at all ── + Check("a district, city_univers and the crash layer are the city's archives", + SdsMeshLoader.IsCityArchive(new FileInfo(Path.Combine(sdsFolder, "city", "greenfield.sds"))) + && SdsMeshLoader.IsCityArchive(new FileInfo(Path.Combine(sdsFolder, "city_univers", "city_univers.sds"))) + && SdsMeshLoader.IsCityArchive(new FileInfo(Path.Combine(sdsFolder, "city_crash", "city_crash_z.sds")))); + Check("an interior and a car are not", + !SdsMeshLoader.IsCityArchive(new FileInfo(Path.Combine(sdsFolder, "shops", "elgreco.sds"))) + && !SdsMeshLoader.IsCityArchive(new FileInfo(Path.Combine(sdsFolder, "cars", "shubert_38.sds")))); + + // ── the interiors: flagged like a district's backdrops, and shown all the same ── + FileInfo? staged = null; + foreach (string interior in interiors) + { + var file = new FileInfo(Path.Combine(sdsFolder, interior + ".sds")); + if (!file.Exists) { sb.AppendLine($" ({interior}: no such archive — skipped)"); continue; } + (List roots, _, _) = SdsMeshLoader.LoadHierarchy(file); + List scenes = [.. roots.Where(r => r.Kind == "Scene")]; + var flagged = new List(); + foreach (SdsFrameNode scene in scenes) Flagged(scene, flagged); + Check($"{interior}: objects of its scenes carry name-table flags a district reads as proxy or snow", + flagged.Count > 0, string.Join(", ", flagged.Take(4))); + Check($"{interior}: none of its scenes is sorted as one", + scenes.Count > 0 && scenes.All(s => s.Category == "Normal"), + string.Join(", ", scenes.Select(s => $"{s.Name} {s.Category}"))); + + List leaves = Opened(roots, out _); + List hidden = [.. leaves.Where(l => !l.IsVisible && l.Lod == 0 && !DefaultHidden.IsEmitterShell(l.Name))]; + Check($"{interior}: every mesh is shown when it opens", leaves.Count > 0 && hidden.Count == 0, + $"{leaves.Count - hidden.Count} of {leaves.Count} shown" + + (hidden.Count == 0 ? "" : "; hidden: " + string.Join(", ", hidden.Take(6).Select(h => h.Name)))); + staged ??= file; + } + + // ── a district: still sorted, its proxy scenes still hidden ── + var district = new FileInfo(Path.Combine(MafiaEnvironment.CityFolder, "greenfield.sds")); + if (district.Exists) + { + (List roots, _, _) = SdsMeshLoader.LoadHierarchy(district); + List proxies = [.. roots.Where(r => r.Kind == "Scene" && r.Category == "Proxy")]; + Check("a district still has scenes sorted as proxy", proxies.Count > 0, + string.Join(", ", roots.Where(r => r.Kind == "Scene").GroupBy(r => r.Category).Select(g => $"{g.Key} {g.Count()}"))); + Opened(roots, out List rows); + List proxyRows = [.. rows.Where(r => r.Category == "Proxy")]; + Check("…and they open hidden, the others shown", + proxyRows.Count > 0 && proxyRows.All(r => !r.IsVisible) + && rows.Any(r => r.Category == "Normal" && r.IsVisible)); + } + else + { + sb.AppendLine(" (no greenfield district — the district steps were skipped)"); + } + + // ── on the stage: the room is in the picture ── + if (staged != null) + { + var host = new D3DImageHost { IsMapViewport = false }; + window = new Window + { + Width = 1100, Height = 760, Left = -20_000, Top = -20_000, ShowInTaskbar = false, + WindowStyle = WindowStyle.None, Content = host, + }; + window.Show(); + Pump(() => host.Rnd != null, 20); + if (host.Rnd == null) { Check("the stage's renderer came up", false); return; } + host.LoadStage(staged, "probe"); + Pump(() => !host.IsLoading && host.MeshCount > 0, 120); + + // The room itself: the largest thing drawn under a scene. + var meshRows = new List(); + foreach (SceneNode root in host.Tree.Roots) Collect(root, meshRows, underScene: false); + SceneNode? shell = meshRows.Where(r => r.Mesh is { Instanced: false }) + .MaxBy(r => Volume(r.Mesh!.BoundsMax - r.Mesh.BoundsMin)); + Check($"{staged.Name} on the stage: its meshes are loaded", shell != null, $"{meshRows.Count} under its scenes"); + if (shell?.Mesh is { } mesh) + { + Check($"the room '{shell.Name}' is shown", shell.IsVisible && mesh.Visible); + Vector3 centre = (mesh.BoundsMin + mesh.BoundsMax) * 0.5f; + host.Rnd.ShowSky = false; + host.LookFrom(centre, centre + new Vector3(3f, 0.5f, -0.4f)); + const int W = 640, H = 400; + byte[]? with = host.CaptureFrame(W, H); + if (with != null) GpuProbes.SavePng(with, W, H, Path.ChangeExtension(outFile, ".png")); + mesh.Visible = false; + byte[]? without = host.CaptureFrame(W, H); + mesh.Visible = true; + int own = 0; + for (int i = 0; with != null && without != null && i < with.Length; i += 4) + { + if (with[i] != without[i] || with[i + 1] != without[i + 1] || with[i + 2] != without[i + 2]) own++; + } + Check("…and drawn: seen from inside, it is most of the picture", own > W * H / 2, $"{own} of {W * H} pixels are its own"); + } + } + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + try { window?.Close(); } + catch (Exception ex) { sb.AppendLine(" (closing the window: " + ex.Message + ")"); } + sb.Insert(0, $"INTERIOR SCENES PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + // The tree the streamer builds for one archive, with the map's default filters applied the way BeginBuild + // applies them: the mesh rows, and the rows of the scenes themselves. + private static List Opened(List roots, out List sceneRows) + { + var leaves = new List(); + var tree = new SceneTree(); + sceneRows = []; + foreach (SdsFrameNode root in roots) + { + SceneNode row = SceneTree.BuildSceneTree(root, leaves); + tree.ApplySceneFilter(row); + if (root.Kind == "Scene") sceneRows.Add(row); + } + return leaves; + } + + private static void Flagged(SdsFrameNode node, List into) + { + if (node.Source is IFrameNode { IsOnNameTable: true, NameTableFlags: not 0 } frame) + { + into.Add($"{node.Name} 0x{frame.NameTableFlags:X}"); + } + foreach (SdsFrameNode child in node.Children) Flagged(child, into); + } + + private static void Collect(SceneNode node, List into, bool underScene) + { + if (underScene && node.Mesh != null) into.Add(node); + foreach (SceneNode child in node.Children) Collect(child, into, underScene || node.Kind == "Scene"); + } + + private static float Volume(Vector3 size) => size.X * size.Y * size.Z; + + private static bool Pump(Func until, int seconds) + { + DateTime end = DateTime.UtcNow.AddSeconds(seconds); + while (DateTime.UtcNow < end) + { + if (until()) return true; + Dispatcher.CurrentDispatcher.Invoke(() => { }, DispatcherPriority.Background); + Thread.Sleep(15); + } + return until(); + } +} diff --git a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs index d79daaa..3cda34f 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs @@ -102,12 +102,12 @@ void Check(string name, bool ok, string detail = "") // ── numbers that are not numbers ── Check("a position that is not finite is refused", - session.ImportObject(probeSds.FullName, source, "probe_live_nan", [float.PositiveInfinity, 0, 0], null, null, 1, out _) != null); + session.ImportObject(probeSds.FullName, source, "probe_live_nan", [float.PositiveInfinity, 0, 0], null, null, 1, null, out _) != null); Check("a heading that is not finite is refused", - session.ImportObject(probeSds.FullName, source, "probe_live_nan", at, float.NaN, null, 1, out _) != null); + session.ImportObject(probeSds.FullName, source, "probe_live_nan", at, float.NaN, null, 1, null, out _) != null); // ── import with a cooked box: one hull, linked ── - string? why = session.ImportObject(probeSds.FullName, source, "probe_live", at, null, "box", 1, out ObjectImportOutcome? outcome); + string? why = session.ImportObject(probeSds.FullName, source, "probe_live", at, null, "box", 1, null, out ObjectImportOutcome? outcome); Check("the mesh is imported with a box for collision", why == null && outcome != null, why ?? $"{outcome!.Collision}; {outcome.NamedSo} thing(s) named '{source}' in the source"); if (why != null) return; @@ -288,7 +288,7 @@ Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) host.Redo(); // ── another imported object renamed ONTO this one's name: this one's record is not written over ── - why = session.ImportObject(probeSds.FullName, source, "probe_live_b", [at[0] + 40f, at[1], at[2]], null, "box", 1, out _); + why = session.ImportObject(probeSds.FullName, source, "probe_live_b", [at[0] + 40f, at[1], at[2]], null, "box", 1, null, out _); if (why == null) { IReadOnlyList mine = [.. ImportLinks.HullsOf(dir, "probe_live_renamed")]; @@ -393,7 +393,7 @@ void GiveName(SceneNode which, string to) ImportLinks.HullsOf(dir, "probe_live").Count == 0 && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 0); // ── the n-th of a name ── - why = session.ImportObject(probeSds.FullName, source, "probe_live_far", at, null, "none", 9999, out _); + why = session.ImportObject(probeSds.FullName, source, "probe_live_far", at, null, "none", 9999, null, out _); Check("an occurrence past the last one is refused, saying how many there are", why != null && why.Contains("thing(s) named"), why ?? "imported"); // ── an import that fails after the copy leaves nothing ── @@ -403,8 +403,8 @@ void GiveName(SceneNode which, string to) File.WriteAllBytes(file, whole[..Math.Max(16, whole.Length / 3)]); } int objectsBefore = Frames(host, district); - string? first = session.ImportObject(probeSds.FullName, source, "probe_live_fail", at, null, null, 1, out _); - string? second = session.ImportObject(probeSds.FullName, source, "probe_live_fail", at, null, null, 1, out _); + string? first = session.ImportObject(probeSds.FullName, source, "probe_live_fail", at, null, null, 1, null, out _); + string? second = session.ImportObject(probeSds.FullName, source, "probe_live_fail", at, null, null, 1, null, out _); Check("an import whose source collision cannot be read is refused, and says nothing was left", first != null && first.Contains("nothing of it was left"), first ?? "imported"); Check("the same import again gets the same answer — the name is not 'already taken' by leftovers", @@ -428,7 +428,7 @@ void GiveName(SceneNode which, string to) { byte[] manifestBefore = File.ReadAllBytes(Path.Combine(dir, "SDSContent.xml")); var namesBefore = new HashSet(Directory.GetFiles(dir).Select(Path.GetFileName)!, StringComparer.OrdinalIgnoreCase); - why = session.ImportObject(interiorSds.FullName, door.EntityName, taken, at, null, null, 1, out _); + why = session.ImportObject(interiorSds.FullName, door.EntityName, taken, at, null, null, 1, null, out _); if (why == null) { sb.AppendLine($" (the pack took an actor named '{taken}' a second time — nothing to check; undone)"); diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 540e63d..53a7bbb 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -189,6 +189,42 @@ void Check(string name, bool ok, string detail = "") FrameTransplant.TryTransplant(document, theirs, scenery, "probe_scenery", FrameTransplant.Standing.Scenery, sceneryWorld, out reason) == null && reason != null, reason ?? ""); + // ── Scenery hung under a frame of the receiving scene: an interior's furniture under its holder ── + // The parent here is the scenery just placed, turned and away from the origin, so the child's own + // matrix has to be worked out against it for the child to stand where it is told to. + Matrix4x4 childWorld = Matrix4x4.CreateRotationZ(1.1f) * Matrix4x4.CreateTranslation(-40f, 7f, 1.5f); + TransplantedObject? carriedChild = FrameTransplant.TryTransplant(document, theirs, scenery, "probe_child", + FrameTransplant.Standing.Scenery, childWorld, shared: null, under: sceneryRoot, out reason); + Check("scenery is copied under a frame of the scene", carriedChild != null, reason ?? ""); + if (carriedChild == null) return; + var childRoot = (FrameObjectSingleMesh)carriedChild.Root; + Check("it is its parent's child, naming the parent's scene in the second slot as a shipped interior's pieces do: no anchored-mesh bit, off the name table", + ReferenceEquals(childRoot.Parent, sceneryRoot) && sceneryRoot.Children.Contains(childRoot) + && childRoot.Refs.TryGetValue(FrameEntryRefTypes.Parent2, out int childAnchor) + && ours.FrameScenes.TryGetValue(childAnchor, out Formats.Frames.Resources.FrameHeaderScene? childScene) + && ReferenceEquals(childScene, BridgeObjectFactory.PickMainScene(ours)) && !childRoot.IsOnFrameTable + && !childRoot.SingleMeshFlags.HasFlag(SingleMeshFlags.ParentIndex2_Flag) + && ours.FrameScenes.Values.All(folder => !folder.Children.Contains(childRoot)) && !carriedChild.IsOnNameTable); + Matrix4x4 stands = childRoot.WorldTransform; + Check("and stands in the world where it was told to, turned as told", + ProbeAssert.Approx(stands.Translation, new Vector3(-40f, 7f, 1.5f)) + && MathF.Abs(stands.M11 - childWorld.M11) < 1e-3f && MathF.Abs(stands.M12 - childWorld.M12) < 1e-3f + && MathF.Abs(stands.M21 - childWorld.M21) < 1e-3f && MathF.Abs(stands.M22 - childWorld.M22) < 1e-3f, + $"at {stands.Translation}, x-axis ({stands.M11:F3}, {stands.M12:F3}) against ({childWorld.M11:F3}, {childWorld.M12:F3})"); + carriedChild.Detach(); + bool gone = !sceneryRoot.Children.Contains(childRoot) && !carriedChild.IsAttached; + carriedChild.Reattach(); + Check("taken out it leaves its parent, put back it is the child again, where it stood", + gone && ReferenceEquals(childRoot.Parent, sceneryRoot) && sceneryRoot.Children.Contains(childRoot) + && ProbeAssert.Approx(childRoot.WorldTransform.Translation, new Vector3(-40f, 7f, 1.5f))); + Check("a parent that is no frame of the receiving scene is refused", + FrameTransplant.TryTransplant(document, theirs, scenery, "probe_child_2", FrameTransplant.Standing.Scenery, + childWorld, shared: null, under: scenery, out reason) == null && reason != null, reason ?? ""); + Check("and so is a parent for an actor's prototype", + FrameTransplant.TryTransplant(document, theirs, scenery, "probe_child_3", FrameTransplant.Standing.Prototype, + childWorld, shared: null, under: sceneryRoot, out reason) == null && reason != null, reason ?? ""); + carriedChild.Detach(); + // ── Undo: the scene is byte for byte what it was; redo: everything is back ── int objectsWith = ours.FrameObjects.Count; carriedScenery.Detach(); @@ -641,6 +677,8 @@ bool OnDisk(ulong hash) Check("a flat thing has no convex hull, and gets a box instead", Assets.Collisions.ConvexHull.Build([new(0, 0, 0), new(1, 0, 0), new(0, 1, 0), new(1, 1, 0)]) == null && Assets.Collisions.ConvexHull.Box([new(0, 0, 0), new(1, 0, 0), new(0, 1, 0)]) is { Triangles.Length: 36 }); + + CheckHeading(Check, sb); } catch (Exception ex) { @@ -743,6 +781,116 @@ private static void CheckShape(string what, TransplantedObject carried, FrameObj // Every point is behind (or on) every face — the hull holds them — and the faces point away from the middle. // The tolerance covers the grid the hull snaps its points to. + // ── The heading an import is asked for: it turns the object about the vertical axis and leaves its tilt ── + // Checked on the rotations themselves, and then on a chair of Francesca's flat, whose mesh is not upright + // in its own space: the frame's matrix stands it up, and a heading that replaced the whole rotation laid + // it on its side. + private static void CheckHeading(Action check, StringBuilder sb) + { + static Quaternion About(Vector3 axis, float angle) => Quaternion.CreateFromAxisAngle(axis, angle); + + Quaternion upright = About(Vector3.UnitZ, 0.7f); + (Quaternion noTilt, float itsHeading) = FrameTransplant.SplitHeading(upright); + check("an upright object's rotation is all heading, and an asked heading is all the rotation it gets", + ProbeAssert.QApprox(noTilt, Quaternion.Identity) && MathF.Abs(itsHeading - 0.7f) < 1e-3f + && ProbeAssert.QApprox(FrameTransplant.WithHeading(upright, -1.2f), About(Vector3.UnitZ, -1.2f)), + $"tilt {noTilt}, heading {itsHeading:F3}"); + + Quaternion quarter = About(Vector3.UnitX, MathF.PI / 2f); + Quaternion stoodUp = Quaternion.Concatenate(quarter, About(Vector3.UnitZ, 2.1f)); + (Quaternion tilt, float heading) = FrameTransplant.SplitHeading(stoodUp); + check("a quarter turn that stands an object up and the heading after it are told apart again", + ProbeAssert.QApprox(tilt, quarter) && MathF.Abs(heading - 2.1f) < 1e-3f, $"tilt {tilt}, heading {heading:F3}"); + check("…and another heading keeps the quarter turn", + ProbeAssert.QApprox(FrameTransplant.WithHeading(stoodUp, 0.4f), Quaternion.Concatenate(quarter, About(Vector3.UnitZ, 0.4f))), ""); + + Quaternion onItsHead = About(Vector3.UnitX, MathF.PI); + check("an object standing on its head has no heading to tell: it is turned as it is", + ProbeAssert.QApprox(FrameTransplant.WithHeading(onItsHead, 0.3f), Quaternion.Concatenate(onItsHead, About(Vector3.UnitZ, 0.3f))), ""); + + var flat = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "shops", "franhome.sds")); + if (!flat.Exists) + { + sb.AppendLine(" (no shops\\franhome.sds — the chair steps were skipped)"); + return; + } + ExtractedSds source = ExtractedSds.Load(SdsMeshLoader.EnsureExtracted(flat)); + FrameResource theirs = source.FrameResource!; + ActorPlacements placements = ActorPlacements.Load(source.Manifest, theirs); + FrameObjectSingleMesh? chair = theirs.FrameObjects.Values.OfType() + .FirstOrDefault(m => m.Name.String == "mesh" && m.Parent?.Name.String == "LFH_zidle"); + if (chair == null || !Matrix4x4.Invert(chair.WorldTransform, out Matrix4x4 toOwn)) + { + sb.AppendLine(" (no mesh under LFH_zidle in shops\\franhome.sds — the chair steps were skipped)"); + return; + } + + // The chair's vertices in its own space, and where a matrix puts them. + Vector3[] own = [.. FrameTransplant.TrianglesOf(chair).SelectMany(m => m.Positions).Select(p => Vector3.Transform(p, toOwn))]; + (Vector3 Min, Vector3 Max) Box(Matrix4x4 world) + { + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + foreach (Vector3 p in own) + { + Vector3 w = Vector3.Transform(p, world); + min = Vector3.Min(min, w); + max = Vector3.Max(max, w); + } + return (min, max); + } + static string Size(Vector3 v) => $"{v.X:F2} x {v.Y:F2} x {v.Z:F2}"; + + Matrix4x4 sourceWorld = chair.WorldTransform * placements.For(chair); + (Vector3 ownMin, Vector3 ownMax) = Box(Matrix4x4.Identity); + Vector3 ownSize = ownMax - ownMin; + (Vector3 thereMin, Vector3 thereMax) = Box(sourceWorld); + float height = thereMax.Z - thereMin.Z; + check("a chair of Francesca's flat is not upright in its own space, and stands in its archive", + ownSize.Y > ownSize.Z * 1.5f && ownSize.Y > ownSize.X * 1.5f && MathF.Abs(height - ownSize.Y) < 0.02f, + $"its own box {Size(ownSize)}, in the archive {Size(thereMax - thereMin)}"); + + var at = new Vector3(40f, -15f, 6f); + bool stands = true, sameHeights = true, onPoint = true; + string sizes = ""; + foreach (float degrees in new[] { 0f, 90f, 213f, -45f }) + { + Matrix4x4 world = FrameTransplant.StandAt(chair, sourceWorld, at, degrees * MathF.PI / 180f); + (Vector3 min, Vector3 max) = Box(world); + stands &= MathF.Abs(max.Z - min.Z - height) < 0.01f; + // The third column is what gives a point its height: the same column, the same tilt. + sameHeights &= MathF.Abs(world.M13 - sourceWorld.M13) < 1e-3f && MathF.Abs(world.M23 - sourceWorld.M23) < 1e-3f + && MathF.Abs(world.M33 - sourceWorld.M33) < 1e-3f; + onPoint &= MathF.Abs(min.Z - at.Z) < 0.005f && min.X < at.X && at.X < max.X && min.Y < at.Y && at.Y < max.Y; + sizes += $"{degrees:F0}°: {Size(max - min)}; "; + } + check("asked for a heading it comes in standing, not on its side", stands, sizes); + check("every point of it is as high above the floor as in its archive: the tilt is the original's", sameHeights, ""); + check("and the bottom of it as it stands is on the point it was put at", onPoint, ""); + + Matrix4x4 unturned = FrameTransplant.StandAt(chair, sourceWorld, at, 0f); + Matrix4x4 turned = FrameTransplant.StandAt(chair, sourceWorld, at, 1f); + (Vector3 unturnedMin, Vector3 unturnedMax) = Box(unturned); + bool parts = Domain.TransformMath.TryDecompose(unturned, out _, out Quaternion before, out _) + & Domain.TransformMath.TryDecompose(turned, out _, out Quaternion after, out _); + check("one heading against another is that turn about the vertical axis and nothing else", + parts && ProbeAssert.QApprox(after, Quaternion.Concatenate(before, About(Vector3.UnitZ, 1f))), ""); + check("with no turn of its own the middle of its bottom is over the point", + MathF.Abs(((unturnedMin.X + unturnedMax.X) / 2f) - at.X) < 0.005f && MathF.Abs(((unturnedMin.Y + unturnedMax.Y) / 2f) - at.Y) < 0.005f, + $"x {unturnedMin.X:F3}..{unturnedMax.X:F3}, y {unturnedMin.Y:F3}..{unturnedMax.Y:F3} around ({at.X}, {at.Y})"); + + Domain.TransformMath.TryDecompose(sourceWorld, out _, out Quaternion there, out _); + Matrix4x4 same = FrameTransplant.StandAt(chair, sourceWorld, at, FrameTransplant.SplitHeading(there).Heading); + Matrix4x4 kept = FrameTransplant.StandAt(chair, sourceWorld, at, null); + static bool SameTurn(Matrix4x4 a, Matrix4x4 b) => + MathF.Abs(a.M11 - b.M11) < 1e-3f && MathF.Abs(a.M12 - b.M12) < 1e-3f && MathF.Abs(a.M13 - b.M13) < 1e-3f + && MathF.Abs(a.M21 - b.M21) < 1e-3f && MathF.Abs(a.M22 - b.M22) < 1e-3f && MathF.Abs(a.M23 - b.M23) < 1e-3f + && MathF.Abs(a.M31 - b.M31) < 1e-3f && MathF.Abs(a.M32 - b.M32) < 1e-3f && MathF.Abs(a.M33 - b.M33) < 1e-3f; + check("asked for the heading it has in its archive it is turned exactly as it is there", SameTurn(same, sourceWorld), ""); + check("with no heading asked it keeps its whole rotation, and its bottom is on the point all the same", + SameTurn(kept, sourceWorld) && MathF.Abs(Box(kept).Min.Z - at.Z) < 0.005f, + $"bottom at z {Box(kept).Min.Z:F3}, asked {at.Z}"); + } + private static bool Encloses((Vector3[] Vertices, int[] Triangles) hull, IReadOnlyList points, float tolerance = 1e-4f) { Vector3 min = new(float.MaxValue), max = new(float.MinValue); diff --git a/src/Illusion/Diagnostics/Probes/ShopPlaceProbes.cs b/src/Illusion/Diagnostics/Probes/ShopPlaceProbes.cs new file mode 100644 index 0000000..506f27c --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/ShopPlaceProbes.cs @@ -0,0 +1,153 @@ +using System.IO; +using System.Numerics; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Assets.World; +using Illusion.Formats.City; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// +/// The places of the game's interiors () against the real install. Every copy of +/// cityshops.bin comes back out of the writer byte for byte; a place is added for the gun shop IN MEMORY - +/// marker, volumes, rows - and found where it was put; taking it out again leaves the table as it was read, byte +/// for byte, and both scenes without the frames; what must be refused is refused. Nothing is written. +/// Output: %TEMP%\illusion_shop_places.txt +/// +internal static class ShopPlaceProbes +{ + internal static void Run() + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_shop_places.txt"); + var sb = new StringBuilder(); + int pass = 0, fail = 0; + void Check(string label, bool ok, string detail = "") + { + if (ok) pass++; else fail++; + sb.AppendLine($"[{(ok ? "PASS" : "FAIL")}] {label}{(detail.Length > 0 ? " - " + detail : "")}"); + } + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + + // ---- every copy of the table (the base game's and each DLC's) through the reader and the writer + foreach (FileInfo copy in LoadZones.Copies()) + { + string? file = Directory.EnumerateFiles(SdsMeshLoader.EnsureExtracted(copy), "cityshops.bin", SearchOption.AllDirectories).FirstOrDefault(); + if (file == null) continue; + byte[] original = File.ReadAllBytes(file); + CityShopsTable read = CityShopsTable.Parse(original); + Check($"cityshops.bin of '{LoadZones.CopyName(copy)}' comes back byte for byte", + original.AsSpan().SequenceEqual(read.ToBytes()), + $"version {read.Version}, {read.Areas.Count} areas, {read.Shops.Count} interiors, {read.Shops.Sum(s => s.Places.Count)} places"); + } + + // ---- a place added and taken out again, in memory + ShopPlaces places = ShopPlaces.Open(f => SdsMeshLoader.EnsureExtracted(f)); + byte[] tableBefore = places.Table.ToBytes(); + int areasBefore = places.Table.Areas.Count; + CityShopsTable.Shop gun = places.Table.Shops.First(s => s.Name == "Gunshop"); + int placesBefore = gun.Places.Count; + sb.AppendLine($"gun shop: {placesBefore} places, {gun.Entities.Count} entities; the table: {areasBefore} areas"); + + Check("an interior the table does not have is refused", places.Add("NoSuchShop", Vector3.Zero, 0, 35, 60, 23, out _) != null); + Check("an unload box no wider than the load box is refused", places.Add("Gunshop", Vector3.Zero, 0, 35, 35, 23, out _) != null); + Check("a place the game ships with is not taken out", places.Remove(gun.Places[1].Marker, out _) != null, places.Remove(gun.Places[1].Marker, out _) ?? ""); + Check("the refusals changed nothing", tableBefore.AsSpan().SequenceEqual(places.Table.ToBytes())); + + var at = new Vector3(-1700f, -260f, -17.97f); + string? refused = places.Add("gunshop", at, 90f, 35f, 60f, 23f, out ShopPlaces.PlaceInfo? made); + Check("a place is added for the gun shop", refused == null && made != null, refused ?? $"{made!.Marker}, {made.LoadZone} / {made.UnloadZone}"); + if (made != null) + { + FrameResource shopScene = places.ShopScene!, cityScene = places.CityScene; + FrameObjectBase? marker = shopScene.FrameObjects.Values.OfType().FirstOrDefault(f => f.Name.String == made.Marker); + Check("its marker is a frame of the scene, on the name table, at the place", marker is FrameObjectDummy { Parent: null, IsOnFrameTable: true } + && Vector3.Distance(marker.WorldTransform.Translation, at) < 1e-3f, marker == null ? "no frame" : marker.WorldTransform.Translation.ToString()); + // a quarter turn counter-clockwise: the marker's own x axis points along the world's y + Check("the marker is turned by the angle asked", marker != null && MathF.Abs(marker.WorldTransform.M11) < 1e-4f && MathF.Abs(marker.WorldTransform.M12 - 1f) < 1e-4f, + marker == null ? "" : $"x axis ({marker.WorldTransform.M11:0.###}, {marker.WorldTransform.M12:0.###}, {marker.WorldTransform.M13:0.###})"); + foreach ((string? name, float half) in new[] { (made.LoadZone, 35f), (made.UnloadZone, 60f) }) + { + FrameObjectArea? volume = cityScene.FrameObjects.Values.OfType().FirstOrDefault(v => v.Name.String == name); + bool holds = volume != null && LoadZones.Contains(volume, at, out _) && LoadZones.Contains(volume, at + new Vector3(half - 1f, 0, 0), out _) + && !LoadZones.Contains(volume, at + new Vector3(half + 1f, 0, 0), out _); + Check($"{name} holds the place and ends {half} m from it", holds); + } + CityShopsTable again = CityShopsTable.Parse(places.Table.ToBytes()); + CityShopsTable.Shop gunAgain = again.Shops.First(s => s.Name == "Gunshop"); + Check("the table reads back with the place and its area row", gunAgain.Places.Count == placesBefore + 1 && again.Areas.Count == areasBefore + 1 + && gunAgain.Places[^1].Marker == made.Marker && gunAgain.Places[^1].Marks.Length == gunAgain.Entities.Count + && again.Areas[^1].LoadZone == made.LoadZone && again.Areas[^1].Archive == "gunshop"); + ShopPlaces.PlaceInfo? listed = places.Shops("Gunshop").First(s => s.Name == "Gunshop").Places.FirstOrDefault(p => p.Marker == made.Marker); + Check("the list shows it as added, with its volumes", listed is { Added: true } && listed.LoadZone == made.LoadZone && listed.UnloadZone == made.UnloadZone); + Check("the game's own places are not shown as added", places.Shops("Gunshop").First(s => s.Name == "Gunshop").Places.Count(p => p.Added) == 1 + + (placesBefore - 11), $"{placesBefore} places before"); + + refused = places.Remove(made.Marker, out ShopPlaces.PlaceInfo? gone); + Check("the added place is taken out", refused == null && gone?.Marker == made.Marker, refused ?? ""); + Check("the table is as it was read, byte for byte", tableBefore.AsSpan().SequenceEqual(places.Table.ToBytes())); + Check("the marker is gone from the interior's scene", !shopScene.FrameObjects.Values.OfType().Any(f => f.Name.String == made.Marker)); + Check("both volumes are gone from city_univers", !cityScene.FrameObjects.Values.OfType().Any(f => f.Name.String == made.LoadZone || f.Name.String == made.UnloadZone)); + + // and the scenes write as the working copies have them + foreach ((FrameResource scene, FileInfo archive) in new[] { (shopScene, places.ShopArchive!), (cityScene, places.CityArchive) }) + { + string file = Formats.Archive.SdsManifest.Load(MafiaEnvironment.ExtractedDir(archive)).GetFiles("FrameResource")[0]; + byte[] disk = File.ReadAllBytes(file), written = scene.WriteToStream(); + Check($"the scene of {archive.Name} writes as it is on disk after the add and the take-out", disk.AsSpan().SequenceEqual(written), $"{disk.Length} bytes on disk, {written.Length} written"); + } + } + + // ---- an interior of one's own, made in memory as a copy of the game's smallest + Check("the game's own interiors are known as such", ShopPlaces.IsShipped("Gunshop") && ShopPlaces.IsShipped("elgreco") && !ShopPlaces.IsShipped("probe_room")); + ShopPlaces fresh = ShopPlaces.Open(f => SdsMeshLoader.EnsureExtracted(f)); + byte[] untouched = fresh.Table.ToBytes(); + int shopsBefore = fresh.Table.Shops.Count, rowsBefore = fresh.Table.Areas.Count; + Check("a name that could not be an archive's is refused", fresh.Create("Bad Name", "elgreco", at, 0, 35, 60, 23, out _) != null); + Check("a name the table already has is refused", fresh.Create("gunshop", "elgreco", at, 0, 35, 60, 23, out _) != null); + Check("an interior that is not there to copy is refused", fresh.Create("probe_room", "no_such_shop", at, 0, 35, 60, 23, out _) != null); + Check("an interior the game ships with is not taken out", fresh.Delete("Gunshop", out _) != null, fresh.Delete("Gunshop", out _) ?? ""); + Check("the refusals changed nothing", untouched.AsSpan().SequenceEqual(fresh.Table.ToBytes()) && fresh.ShopArchive == null); + CityShopsTable.Shop greco = fresh.Table.Shops.First(s => s.Name.Equals("elgreco", StringComparison.OrdinalIgnoreCase)); + var roomAt = new Vector3(-1700f, -260f, -17.8f); + string? notMade = fresh.Create("probe_room", "elgreco", roomAt, 0f, 35f, 60f, 23f, out ShopPlaces.PlaceInfo? room); + Check("an interior is made as a copy of El Greco's", notMade == null && room != null, notMade ?? $"{room!.Marker}, {room.LoadZone} / {room.UnloadZone}"); + if (room != null) + { + FrameResource scene = fresh.ShopScene!; + List frames = [.. scene.FrameObjects.Values.OfType()]; + FrameObjectBase? carrier = frames.FirstOrDefault(f => f.Name.String == "PROBE_ROOM_translocator_00"); + FrameObjectBase? mark = frames.FirstOrDefault(f => f.Name.String == room.Marker); + Check("the frame that carries the interior bears the new name and still carries it", carrier is { Children.Count: > 0 }, $"{carrier?.Children.Count} children"); + Check("its first place is a marker of its own at the point", room.Marker == "PROBE_ROOM_translocator_01" && mark is FrameObjectDummy { Children.Count: 0 } + && Vector3.Distance(mark.WorldTransform.Translation, roomAt) < 1e-3f); + Check("the copied interior's own markers are gone from the copy", !frames.Any(f => (f.Name.String ?? "").StartsWith("Lokace_ElGreco_translocator", StringComparison.OrdinalIgnoreCase))); + CityShopsTable back = CityShopsTable.Parse(fresh.Table.ToBytes()); + CityShopsTable.Shop? made2 = back.Shops.FirstOrDefault(s => s.Name == "probe_room"); + Check("the table reads back with a row of its own, the copied entities and one place", back.Shops.Count == shopsBefore + 1 && made2 != null + && made2.Holder == "PROBE_ROOM_translocator_00" && made2.Entities.SequenceEqual(greco.Entities) && made2.ActorFile == greco.ActorFile + && made2.Places.Count == 1 && made2.Places[0].Marker == room.Marker && made2.Places[0].Marks.Length == greco.Entities.Count); + Check("and an area row naming its archive, with volumes named after it", back.Areas.Count == rowsBefore + 1 && back.Areas[^1].Archive == "probe_room" + && back.Areas[^1].LoadZone == room.LoadZone && room.LoadZone!.EndsWith("_1_probe_room", StringComparison.Ordinal) + && room.UnloadZone!.EndsWith("_0_probe_room", StringComparison.Ordinal)); + Check("nothing was written: no archive, no working copy", fresh.ShopArchiveIsNew && !fresh.ShopArchive!.Exists + && !Directory.Exists(MafiaEnvironment.ExtractedDir(fresh.ShopArchive))); + } + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception - " + ex); + fail++; + } + finally + { + sb.Insert(0, $"SHOP PLACES PROBE: {pass} passed, {fail} failed{Environment.NewLine}"); + File.WriteAllText(outFile, sb.ToString()); + } + } +} diff --git a/src/Illusion/Diagnostics/Probes/WorldLightProbes.cs b/src/Illusion/Diagnostics/Probes/WorldLightProbes.cs new file mode 100644 index 0000000..1d48b92 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/WorldLightProbes.cs @@ -0,0 +1,234 @@ +using System.Globalization; +using System.IO; +using System.Numerics; +using System.Reflection; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Hashing; +using Illusion.Formats.Mathematics; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// +/// What the game's light frames and sectors hold, read off the shipped archives, with the lights split by where +/// they stand: inside a room-sized sector, inside a district-sized one, or in no sector at all. Every field of a +/// light is counted per group, so a field that tells indoor lights from street lights shows up as a different +/// set of values. Nothing is written. It exists to learn how interiors are lit. +/// Output: %TEMP%\illusion_world_lights.txt +/// +internal static class WorldLightProbes +{ + private const float RoomSized = 150f; + private static readonly string[] Groups = ["room", "district", "none"]; + + private sealed record Seen(string Archive, FrameObjectLight Light, string Group, string Sector); + + internal static void Run(string[] archives) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_world_lights.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (archives.Length == 0) + { + archives = [@"city\italy", @"city\midtown", @"city\uppertown", @"city\southport", @"city\chinatown", @"city\joesflat", + @"city\gvinterier", @"city\prisoni", @"city\marketarcade", @"shops\gunshop", @"shops\deli", @"shops\harry", @"shops\odevy"]; + } + + var lights = new List(); + foreach (string name in archives) + { + var file = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", name + ".sds")); + sb.AppendLine($"════ {name} ════"); + if (!file.Exists) { sb.AppendLine(" no such archive"); continue; } + if (SdsMeshLoader.OpenScene(SdsMeshLoader.EnsureExtracted(file)).FrameResource is not { FrameObjects: not null } fr) { sb.AppendLine(" no frames"); continue; } + + List sectors = fr.FrameObjects.Values.OfType().ToList(); + var boxes = new Dictionary(); + foreach (FrameObjectSector s in sectors) boxes[s] = World(s.Bounds, s.WorldTransform); + var under = sectors.ToDictionary(s => s, _ => 0); + + int before = lights.Count; + foreach (FrameObjectLight l in fr.FrameObjects.Values.OfType()) + { + Vector3 at = l.WorldTransform.Translation; + // the sector it hangs under, else the smallest sector whose box it stands in + FrameObjectSector? sector = Ancestors(l).OfType().FirstOrDefault() + ?? sectors.Where(s => Inside(boxes[s], at)).OrderBy(s => Diagonal(boxes[s])).FirstOrDefault(); + string group = sector == null ? "none" : Diagonal(boxes[sector]) <= RoomSized ? "room" : "district"; + if (sector != null) under[sector]++; + lights.Add(new Seen(name, l, group, sector == null ? "-" : Named(sector.Name))); + } + + sb.AppendLine($" sectors {sectors.Count}, lights {lights.Count - before}"); + sb.AppendLine(" sector size (m) planes unk08 unk13 unk14 lights children"); + foreach (FrameObjectSector s in sectors.OrderBy(s => Diagonal(boxes[s])).Take(40)) + { + Vector3 size = boxes[s].Max - boxes[s].Min; + sb.AppendLine($" {Named(s.Name),-40} {V(size),-23} {s.Planes?.Length ?? 0,6} {s.Unk08,6} {V(s.Unk13),-24} {V(s.Unk14),-24} {under[s],6} {s.Children.Count,9}" + + $" centre {V((boxes[s].Min + boxes[s].Max) / 2)}"); + } + if (sectors.Count > 40) sb.AppendLine($" ... and {sectors.Count - 40} larger"); + } + + sb.AppendLine(); + sb.AppendLine("════ lights by where they stand ════"); + foreach (string g in Groups) sb.AppendLine($" {g,-9} {lights.Count(s => s.Group == g)}"); + + sb.AppendLine(); + sb.AppendLine("════ flags, bit by bit: share of the group's lights with the bit set ════"); + sb.AppendLine(" bit " + string.Join(" ", Groups.Select(g => $"{g,9}"))); + for (int bit = 0; bit < 32; bit++) + { + int mask = 1 << bit; + if (!lights.Any(s => (s.Light.Flags & mask) != 0)) continue; + sb.AppendLine($" 0x{mask:X8} " + string.Join(" ", Groups.Select(g => Share(lights.Where(s => s.Group == g).ToList(), s => (s.Light.Flags & mask) != 0)))); + } + + sb.AppendLine(); + sb.AppendLine("════ every field, per group: distinct values, range, the commonest ════"); + foreach (PropertyInfo p in typeof(FrameObjectLight).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)) + { + sb.AppendLine($" {p.Name}"); + foreach (string g in Groups) + { + List values = lights.Where(s => s.Group == g).Select(s => p.GetValue(s.Light)).ToList(); + if (values.Count == 0) continue; + sb.AppendLine($" {g,-9} {Stat(values)}"); + } + } + + sb.AppendLine(); + sb.AppendLine("════ a few lights of each group in full ════"); + foreach (string g in Groups) + { + foreach (Seen s in lights.Where(s => s.Group == g).Take(4)) + { + sb.AppendLine($" [{g}] {s.Archive}: {Named(s.Light.Name)} in {s.Sector}; parent {(s.Light.Parent == null ? "-" : s.Light.Parent.GetType().Name.Replace("FrameObject", "") + " " + Named(s.Light.Parent.Name))}"); + sb.AppendLine(" " + string.Join(" ", typeof(FrameObjectLight).GetProperties(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly) + .Select(p => p.Name.Replace("UnkVector_", "V").Replace("LUnk", "L") + "=" + Text(p.GetValue(s.Light))))); + } + } + } + catch (Exception ex) + { + sb.AppendLine("EXCEPTION: " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + /// + /// The frames of one archive whose names hold a given text: kind, parents, place and turn in the world. + /// Arguments: an archive under pc\sds without the extension, then name parts. + /// Output: %TEMP%\illusion_frames.txt + /// + internal static void Frames(string[] args) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_frames.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (args.Length < 2) { sb.AppendLine("usage: archive namePart ..."); return; } + var file = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", args[0] + ".sds")); + if (!file.Exists) { sb.AppendLine("no such archive"); return; } + if (SdsMeshLoader.OpenScene(SdsMeshLoader.EnsureExtracted(file)).FrameResource is not { FrameObjects: not null } fr) { sb.AppendLine("no frames"); return; } + List all = fr.FrameObjects.Values.OfType().ToList(); + sb.AppendLine($"{args[0]}: {all.Count} frames; kinds: " + string.Join(", ", all.GroupBy(f => f.GetType().Name.Replace("FrameObject", "")).Select(g => $"{g.Key} {g.Count()}"))); + foreach (string part in args.Skip(1)) + { + List hits = all.Where(f => Named(f.Name).Contains(part, StringComparison.OrdinalIgnoreCase)).ToList(); + sb.AppendLine($"'{part}': {hits.Count}"); + foreach (FrameObjectBase f in hits.OrderBy(f => Named(f.Name), StringComparer.OrdinalIgnoreCase)) + { + Matrix4x4 w = f.WorldTransform; + sb.AppendLine($" {f.GetType().Name.Replace("FrameObject", ""),-10} {Named(f.Name),-40} at {V(w.Translation),-32} x-axis {V(new Vector3(w.M11, w.M12, w.M13)),-24} " + + $"parent {(f.Parent == null ? "-" : Named(f.Parent.Name))} root {(f.Root == null ? "-" : Named(f.Root.Name))} children {f.Children.Count} table {f.IsOnFrameTable}" + + $" slot2 {(f.Refs.TryGetValue(Formats.Frames.FrameEntryRefTypes.Parent2, out int slot2) ? (fr.FrameScenes.ContainsKey(slot2) ? "scene" : "object") : "none")}" + + (f is Formats.Frames.ObjectTypes.FrameObjectSingleMesh sm ? $" flags 0x{(uint)sm.SingleMeshFlags:X8}" : "") + // what an import of it would bring: the box of everything its subtree draws, and whether + // an actor of the archive is tied to it (such a frame is not drawn without its actor) + + (Assets.Frames.FrameTransplant.BoundsOf(f) is { } box ? $" size {V(box.Max - box.Min)}" : "") + + (Assets.Frames.FrameTransplant.BaseOf(f) is { } foot ? $" stands {V(Vector3.Transform(foot, w))}" : "") + + (f is Formats.Frames.ObjectTypes.FrameObjectFrame { ActorHash.Hash: not 0 } tied ? $" actor {Named(tied.ActorHash)}" : "")); + } + } + } + catch (Exception ex) + { + sb.AppendLine("EXCEPTION: " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static IEnumerable Ancestors(FrameObjectBase frame) + { + var seen = new HashSet(); + for (FrameObjectBase? p = frame.Parent; p != null && seen.Add(p); p = p.Parent) yield return p; + if (frame.Root != null && seen.Add(frame.Root)) yield return frame.Root; + } + + private static (Vector3 Min, Vector3 Max) World(BoundingBox box, Matrix4x4 world) + { + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + for (int i = 0; i < 8; i++) + { + var corner = new Vector3((i & 1) == 0 ? box.Min.X : box.Max.X, (i & 2) == 0 ? box.Min.Y : box.Max.Y, (i & 4) == 0 ? box.Min.Z : box.Max.Z); + Vector3 at = Vector3.Transform(corner, world); + min = Vector3.Min(min, at); + max = Vector3.Max(max, at); + } + return (min, max); + } + + private static bool Inside((Vector3 Min, Vector3 Max) box, Vector3 at) => + at.X >= box.Min.X && at.X <= box.Max.X && at.Y >= box.Min.Y && at.Y <= box.Max.Y && at.Z >= box.Min.Z && at.Z <= box.Max.Z; + + private static float Diagonal((Vector3 Min, Vector3 Max) box) => (box.Max - box.Min).Length(); + + private static string Share(List group, Func has) => + group.Count == 0 ? $"{"-",9}" : $"{100.0 * group.Count(has) / group.Count,8:0.#}%"; + + private static string Stat(List values) + { + var counted = values.GroupBy(Text).OrderByDescending(v => v.Count()).ToList(); + string range = ""; + if (values[0] is float) + { + List numbers = values.OfType().Where(float.IsFinite).ToList(); + if (numbers.Count > 0) range = $"{F(numbers.Min())}..{F(numbers.Max())}; "; + } + return $"{counted.Count,4} distinct; {range}" + string.Join(", ", counted.Take(5).Select(v => $"{v.Key} x{v.Count()}")); + } + + private static string Text(object? value) => value switch + { + null => "null", + float f => F(f), + int i => i is > 255 or < 0 ? "0x" + i.ToString("X8", CultureInfo.InvariantCulture) : i.ToString(CultureInfo.InvariantCulture), + byte b => b.ToString(CultureInfo.InvariantCulture), + Vector3 v => V(v), + HashName n => n.Hash == 0 ? "-" : Named(n), + HashName[] names => names.All(n => n.Hash == 0) ? "-" : "[" + string.Join(", ", names.Select(n => n.Hash == 0 ? "-" : Named(n))) + "]", + BoundingBox box => box.Min.X < -1e30f || box.Max.X > 1e30f ? "open" : "box " + V(box.Max - box.Min), + Matrix4x4 m => m.IsIdentity ? "identity" : "at " + V(m.Translation), + _ => value.ToString() ?? "", + }; + + private static string Named(HashName name) => name.String is { Length: > 0 } s ? s : "0x" + name.Hash.ToString("X16", CultureInfo.InvariantCulture); + + private static string F(float v) => !float.IsFinite(v) ? v.ToString(CultureInfo.InvariantCulture) + : MathF.Abs(v) >= 1e6f ? v.ToString("0.##e0", CultureInfo.InvariantCulture) : v.ToString("0.###", CultureInfo.InvariantCulture); + + private static string V(Vector3 v) => $"({F(v.X)}, {F(v.Y)}, {F(v.Z)})"; +} diff --git a/src/Illusion/Diagnostics/Probes/WorldProbes.cs b/src/Illusion/Diagnostics/Probes/WorldProbes.cs index 2ea64df..fc517e1 100644 --- a/src/Illusion/Diagnostics/Probes/WorldProbes.cs +++ b/src/Illusion/Diagnostics/Probes/WorldProbes.cs @@ -170,7 +170,8 @@ internal static void RunMapProbe() } // StreamMap catalog: scenes/lines, the richest line and actual load of its asset. - internal static void RunStreamMapProbe() + // Arguments: parts of scene names - each matching scene is listed line by line with what it loads. + internal static void RunStreamMapProbe(string[]? dump = null) { string outFile = Path.Combine(Path.GetTempPath(), "illusion_streammap.txt"); var sb = new StringBuilder(); @@ -195,6 +196,26 @@ internal static void RunStreamMapProbe() foreach (StreamScene s in cat.Scenes.OrderByDescending(s => s.Lines.Count).Take(5)) sb.AppendLine($" {s.Name}: {s.Lines.Count} lines"); + foreach (string part in dump ?? []) + { + foreach (StreamScene s in cat.Scenes.Where(s => s.Name.Contains(part, StringComparison.OrdinalIgnoreCase))) + { + sb.AppendLine(); + sb.AppendLine($"SCENE '{s.Name}': {s.Lines.Count} lines"); + foreach (StreamSceneLine line in s.Lines) + { + sb.AppendLine($" line '{line.Name}' (lineID={line.LineID})"); + foreach (StreamAsset a in line.Assets) sb.AppendLine($" {a.Type,-18} {a.Path}{(File.Exists(a.DiskPath) ? "" : " (no such file)")}"); + } + } + } + if (dump is { Length: > 0 }) + { + sb.AppendLine(); + sb.AppendLine("All scenes: " + string.Join(", ", cat.Scenes.Select(s => $"{s.Name} ({s.Lines.Count})"))); + return; + } + StreamSceneLine? rich = cat.RichestLine; sb.AppendLine($"\nRichest line: '{rich?.SceneName}' / '{rich?.Name}' (lineID={rich?.LineID}) — {rich?.RenderableCount} renderable assets"); if (rich != null) diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index d360414..dfae4dc 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -741,14 +741,16 @@ public CameraInfo Camera() public string? ImportActor(string sourceActFile, string actorName, string newName, float[] position) { - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + // The pack of the editor the tools are pointed at: the loaded area's, or - with the resource editor as + // the target - that of the archive open there (an interior under shops\ is no area of the map editor, + // and this is how it is given lights). + if (TargetHost is not { } host) return _resourceTarget ? "the resource editor has no archive open — resource_open first" : NotOpen; if (host.BridgeEditedCount > 0) return "a Blender edit session is open — blender_end first"; if (position is not { Length: 3 } || position.Any(v => !float.IsFinite(v))) return "position takes three finite numbers"; if (!File.Exists(sourceActFile)) return $"no such file: {sourceActFile}"; SceneNode? actorsRow = AllNodes(host).FirstOrDefault(n => n.Source is Assets.Adapters.ActorDocumentAdapter); - if (actorsRow == null) return "the loaded area has no actor pack to add to"; + if (actorsRow == null) return _resourceTarget ? "the open archive has no actor pack to add to" : "the loaded area has no actor pack to add to"; Formats.Actors.ActorsFile source; try @@ -766,34 +768,47 @@ public CameraInfo Camera() SceneNode? node = host.ActorEditing.Import( actorsRow, source, actor, newName, new Vector3(position[0], position[1], position[2]), out string? reason); if (node == null) return reason ?? "the pack refused the actor"; - _resourceTarget = false; // a map-only tool: what follows it is about the map return null; } public string? ImportObject(string sourceArchive, string name, string newName, float[] position, float? yawDegrees, - string? collision, int occurrence, out ObjectImportOutcome? outcome) + string? collision, int occurrence, string? parent, out ObjectImportOutcome? outcome) { outcome = null; if (position is not { Length: 3 } || position.Any(v => !float.IsFinite(v))) return "position takes three finite numbers"; if (yawDegrees is { } yaw && !float.IsFinite(yaw)) return "yawDegrees is not a finite number"; - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; - if (host.BridgeEditedCount > 0) return "a Blender edit session is open — blender_end first"; - if (window.WholeMapCheck.IsChecked == true || window.AreaCombo.SelectedItem is not MapArea area) - { - return "load one district first — an import needs one archive to go into"; + // The archive the copy goes into: the loaded district's, or - with the resource editor as the target - + // the one open there (an interior under shops\ is no area of the map editor, and this is how it is + // furnished). + D3DImageHost host; + FileInfo destination; + if (_resourceTarget) + { + if (ResourceWindow is not { StagedEntry: { } staged } resources) return "the resource editor has no archive open — resource_open first"; + host = resources.Stage; + destination = staged.File; + } + else + { + if (Window is not { } window) return NotOpen; + host = window.Viewport; + if (window.WholeMapCheck.IsChecked == true || window.AreaCombo.SelectedItem is not MapArea area) + { + return "load one district first — an import needs one archive to go into"; + } + destination = area.FileFor(window.WinterToggle.IsChecked == true); } - FileInfo destination = area.FileFor(window.WinterToggle.IsChecked == true); + if (host.BridgeEditedCount > 0) return "a Blender edit session is open — blender_end first"; + SceneNode? under = null; + if (!string.IsNullOrWhiteSpace(parent) && Resolve(host, parent, out under) is { } unresolved) return unresolved; Assets.Collisions.CollisionChoice hulls = Assets.Collisions.CollisionChoice.Auto; if (!string.IsNullOrEmpty(collision) && !Enum.TryParse(collision, ignoreCase: true, out hulls)) { return $"collision '{collision}' is none of auto, convex, box, mesh, none"; } - string? refused = host.ObjectImporting.Import(destination, sourceArchive, name, newName, - new Vector3(position[0], position[1], position[2]), yawDegrees, out outcome, hulls, occurrence); - if (refused == null) _resourceTarget = false; // a map-only tool: what follows it is about the map - return refused; + return host.ObjectImporting.Import(destination, sourceArchive, name, newName, + new Vector3(position[0], position[1], position[2]), yawDegrees, out outcome, hulls, occurrence, under); } public string? DuplicateSelected(out IReadOnlyList copies) @@ -1643,6 +1658,184 @@ private static IEnumerable Descendants(DependencyObject root) } } + public string? ArchiveMaterials(string archive, bool all, string? saveTo, string? libraryTo, out ArchiveMaterialsInfo? result) + { + result = null; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(archive)) return "archive is a full path to an .sds, or a path under pc\\sds such as 'cars/shubert_38.sds'"; + string asked = archive.Trim().Replace('/', Path.DirectorySeparatorChar); + if (!asked.EndsWith(".sds", StringComparison.OrdinalIgnoreCase)) asked += ".sds"; + var file = new FileInfo(Path.IsPathRooted(asked) ? asked : Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", asked)); + if (!file.Exists) return $"no such archive: {file.FullName}"; + string? target = string.IsNullOrWhiteSpace(saveTo) ? null : saveTo.Trim(); + if (target != null && !Path.IsPathRooted(target)) return "saveTo is the full path of the file to write"; + if (target != null && Directory.Exists(target)) return "saveTo names a folder - give the file to write, e.g. ...\\materials.json"; + string? library = string.IsNullOrWhiteSpace(libraryTo) ? null : libraryTo.Trim(); + if (library != null && !Path.IsPathRooted(library)) return "libraryTo is the full path of the .mtl file to write"; + if (library != null && Directory.Exists(library)) return "libraryTo names a folder - give the file to write, e.g. ...\\my_interior.mtl"; + if (library != null && !library.EndsWith(".mtl", StringComparison.OrdinalIgnoreCase)) return "libraryTo is a material library - name it .mtl"; + // Never one of the game's own: a library written over default.mtl would be the game with a handful of materials. + if (library != null && Path.GetFullPath(library).StartsWith( + Path.GetFullPath(Path.Combine(Assets.MafiaEnvironment.GameRoot, "edit", "materials")) + Path.DirectorySeparatorChar, + StringComparison.OrdinalIgnoreCase)) + { + return "libraryTo is inside the game's own edit\\materials - write the library somewhere else"; + } + try + { + IReadOnlyList libraries = Assets.Materials.ArchiveMaterials.LoadLibraries(Assets.MafiaEnvironment.GameRoot); + Assets.Materials.ArchiveMaterials.Report report = Assets.Materials.ArchiveMaterials.Read(file, libraries); + System.Text.Json.Nodes.JsonObject document = Assets.Materials.ArchiveMaterials.ToJson(report, all); + if (target != null) Assets.Materials.ArchiveMaterials.Save(document, target); + int inLibrary = library != null ? Assets.Materials.ArchiveMaterials.SaveLibrary(report, libraries, library) : 0; + int Of(Assets.Materials.MaterialOrigin origin) => report.Materials.Count(m => m.Origin == origin); + result = new ArchiveMaterialsInfo(report.Archive, file.FullName, report.OriginKnown, report.Materials.Count + report.Missing.Count, + Of(Assets.Materials.MaterialOrigin.Added), Of(Assets.Materials.MaterialOrigin.Changed), Of(Assets.Materials.MaterialOrigin.Shipped), + report.Missing.Count, document, target, inLibrary > 0 ? library : null, inLibrary); + return null; + } + catch (Exception ex) when (ZoneWrites.IsFileTrouble(ex) || ex is NotSupportedException) + { + return "could not read the archive's materials: " + ex.Message; + } + } + + private static ShopPlaceInfo Told(Assets.World.ShopPlaces.PlaceInfo p) => + new(p.Shop, p.Archive, p.Marker, p.At is { } at ? Xyz(at) : null, p.Turn, [p.MapX, p.MapY], p.LoadZone, p.UnloadZone, p.Added); + + public string? ShopPlaces(string? shop, out IReadOnlyList shops) + { + shops = []; + if (EnsureEnvironment() is { } notReady) return notReady; + string? asked = string.IsNullOrWhiteSpace(shop) ? null : shop.Trim(); + try + { + Assets.World.ShopPlaces places = Assets.World.ShopPlaces.Open(f => Assets.Sds.SdsMeshLoader.EnsureExtracted(f)); + IReadOnlyList all = places.Shops(asked); + if (asked != null) + { + all = [.. all.Where(s => string.Equals(s.Name, asked, StringComparison.OrdinalIgnoreCase) || string.Equals(s.Archive, asked, StringComparison.OrdinalIgnoreCase))]; + if (all.Count == 0) return $"the table has no interior named '{asked}' - call it without a name for the list"; + } + shops = [.. all.Select(s => new ShopInfo(s.Name, s.Archive, s.ActorFile, s.Entities, [.. s.Places.Select(Told)]))]; + return null; + } + catch (Exception ex) when (ZoneWrites.IsFileTrouble(ex)) + { + return "could not read the interiors: " + ex.Message; + } + } + + public string? ShopPlaceAdd(string shop, float[] point, float turn, float loadHalf, float unloadHalf, float halfHeight, bool apply, + out ShopPlaceInfo? place, out IReadOnlyList archives) + { + place = null; + archives = []; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(shop)) return "shop is the interior's name, as shop_places lists it"; + if (point is not { Length: 3 } || point.Any(v => !float.IsFinite(v))) return "point is [x, y, z], finite numbers"; + return ChangeShopPlaces(apply, "add the place", out place, out archives, + (Assets.World.ShopPlaces places, out Assets.World.ShopPlaces.PlaceInfo? made) => + places.Add(shop.Trim(), new Vector3(point[0], point[1], point[2]), turn, loadHalf, unloadHalf, halfHeight, out made)); + } + + public string? ShopPlaceDelete(string marker, bool apply, out ShopPlaceInfo? place, out IReadOnlyList archives) + { + place = null; + archives = []; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(marker)) return "marker is the place's marker, as shop_places lists it"; + return ChangeShopPlaces(apply, "take the place out", out place, out archives, + (Assets.World.ShopPlaces places, out Assets.World.ShopPlaces.PlaceInfo? gone) => places.Remove(marker.Trim(), out gone)); + } + + public string? ShopCreate(string name, string like, float[] point, float turn, float loadHalf, float unloadHalf, float halfHeight, bool apply, + out ShopPlaceInfo? place, out IReadOnlyList archives, out IReadOnlyList notes) + { + place = null; + archives = []; + notes = []; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(name)) return "name is the new interior's name"; + if (string.IsNullOrWhiteSpace(like)) return "like is the interior to copy, as shop_places lists it"; + if (point is not { Length: 3 } || point.Any(v => !float.IsFinite(v))) return "point is [x, y, z], finite numbers"; + var told = new List(); + string? refused = ChangeShopPlaces(apply, "make the interior", out place, out archives, + (Assets.World.ShopPlaces places, out Assets.World.ShopPlaces.PlaceInfo? made) => + places.Create(name.Trim(), like.Trim(), new Vector3(point[0], point[1], point[2]), turn, loadHalf, unloadHalf, halfHeight, out made), + told); + notes = told; + return refused; + } + + public string? ShopDelete(string name, bool apply, out ShopInfo? shop, out IReadOnlyList archives) + { + shop = null; + archives = []; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(name)) return "name is the interior to take out, as shop_places lists it"; + try + { + Assets.World.ShopPlaces places = Assets.World.ShopPlaces.Open(f => Assets.Sds.SdsMeshLoader.EnsureExtracted(f)); + if (ZoneWrites.StructureBlocked(places.CityArchive) is { } blocked) return blocked; + if (places.Delete(name.Trim(), out Assets.World.ShopPlaces.ShopInfo? gone) is { } refused) return refused; + shop = new ShopInfo(gone!.Name, gone.Archive, gone.ActorFile, gone.Entities, [.. gone.Places.Select(Told)]); + archives = [places.CityArchive.FullName]; + if (!apply) return null; + places.Save(); + ZoneWrites.Written(places.CityArchive); + return null; + } + catch (Exception ex) when (ZoneWrites.IsFileTrouble(ex)) + { + return "could not take the interior out: " + ex.Message; + } + } + + private delegate string? ShopPlaceChange(Assets.World.ShopPlaces places, out Assets.World.ShopPlaces.PlaceInfo? place); + + // A place is two archives changed together. Neither may be open in an editor: that editor holds a scene of + // its own and its next save would write it whole, over the marker or the volumes written here. + private static string? ChangeShopPlaces(bool apply, string what, out ShopPlaceInfo? place, out IReadOnlyList archives, ShopPlaceChange change, + List? notes = null) + { + place = null; + archives = []; + try + { + Assets.World.ShopPlaces places = Assets.World.ShopPlaces.Open(f => Assets.Sds.SdsMeshLoader.EnsureExtracted(f)); + if (ZoneWrites.StructureBlocked(places.CityArchive) is { } blocked) return blocked; + if (change(places, out Assets.World.ShopPlaces.PlaceInfo? changed) is { } refused) return refused; + FileInfo shopArchive = places.ShopArchive!; + bool isNew = places.ShopArchiveIsNew; + if (!isNew && ZoneWrites.HeldByAnEditor(shopArchive) is { } held) return held; + place = Told(changed!); + archives = isNew ? [places.CityArchive.FullName] : [shopArchive.FullName, places.CityArchive.FullName]; + if (!apply) return null; + places.Save(); + if (isNew) + { + // an archive the game does not have yet: packed here, where nothing is overwritten - and the game + // finds archives through a cached list of files that a new one is not in + Assets.Sds.SdsWriter.PackResult packed = Assets.Sds.SdsWriter.PackSds(shopArchive, createBackup: false); + notes?.Add($"packed the new archive {packed.Archive}"); + notes?.Add(Assets.Sds.GameFileIndex.Reset() + ? "the game's file list (vfs.bin) was reset - the next start rebuilds it with the new archive" + : $"the game's file list was not reset - remove {Assets.Sds.GameFileIndex.Path} before starting the game, or it will not find the new archive"); + } + else + { + ZoneWrites.Written(shopArchive); + } + ZoneWrites.Written(places.CityArchive); + return null; + } + catch (Exception ex) when (ZoneWrites.IsFileTrouble(ex)) + { + return $"could not {what}: " + ex.Message; + } + } + public string? ZoneMoveFace(string zone, string face, float to, bool apply, string? copy, out IReadOnlyList results) { results = []; diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index 7ba3334..3f73fba 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -127,15 +127,19 @@ private static void Apply(IReadOnlyList edits) /// scenery with its collision. Null on success; otherwise why not. /// /// The source .sds: a full path, or one relative to the game's sds folder. - /// Heading about the vertical axis, replacing the original's; null keeps it. + /// Heading about the vertical axis, replacing the original's heading — its tilt + /// stays, so what stands upright in the source stands upright here; null keeps the heading too. /// What collision scenery brings (an actor's object always brings its own). /// Which of the things answering to in the source, /// counting from 1: its actors of that name first, then its frame objects — the ones that draw and that no /// actor places before the helpers that share their name. Shipped scenes repeat names heavily (one interior /// has 87 objects called 'lahev'), and without this only the first could ever be asked for. + /// The row of a frame of to hang a piece of scenery + /// under, instead of standing it in the scene by itself — an interior's holder, which carries its children + /// to wherever the interior is placed. The copy still lands on in the world. public string? Import(FileInfo destination, string sourceArchive, string name, string newName, Vector3 at, float? yawDegrees, out Mcp.ObjectImportOutcome? outcome, Assets.Collisions.CollisionChoice hulls = default, - int occurrence = 1) + int occurrence = 1, SceneNode? under = null) { outcome = null; if (_host.BridgeEditedCount > 0) return "a Blender edit session is open — end it first"; @@ -153,6 +157,16 @@ private static void Apply(IReadOnlyList edits) } SceneNode? actorsRow = AllNodes().FirstOrDefault( n => n.Source is Assets.Adapters.ActorDocumentAdapter a && ReferenceEquals(a.Scene, scene)); + FrameObjectBase? parent = null; + if (under != null) + { + if (under.Source is not FrameNodeAdapter { Frame: { } wanted } + || !scene.Frame.FrameObjects.TryGetValue(wanted.RefID, out object? held) || !ReferenceEquals(held, wanted)) + { + return $"'{under.Name}' is not a frame of {destination.Name} — only one of its own frames can be the parent"; + } + parent = wanted; + } var sds = new FileInfo(Path.IsPathRooted(sourceArchive) ? sourceArchive @@ -193,10 +207,6 @@ void TakeCarryBack() (Formats.Frames.ExtractedSds source, Assets.Actors.ActorPlacements? read) = SourceOf(sourceDir); if (source.FrameResource is not { } theirs || read is not { } theirPlacements) return $"{sds.Name} carries no scene"; - Quaternion? facing = yawDegrees is { } yaw - ? Quaternion.CreateFromAxisAngle(Vector3.UnitZ, yaw * MathF.PI / 180f) - : null; - // Everything in the source that answers to the name: its actors, then its frame objects — those // that draw and that no actor places first, which is what a library card of that name shows. List named = @@ -240,6 +250,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO { return $"'{name}' places no object of its archive's scene — it travels with actor_import"; } + if (parent != null) return $"'{name}' is an actor's object — its actor places it, so it takes no parent"; if (actorsRow == null) return $"{destination.Name} has no actor pack to add an actor to"; if (theirPlacements.PackOf(actor) is not { } theirPack) return $"'{name}' belongs to no pack"; @@ -247,6 +258,10 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO Assets.Frames.FrameTransplant.Standing.Prototype, Matrix4x4.Identity, shared, out reason); if (carried == null) return reason ?? "the object could not be copied"; carry = Carry(carried); + // The actor's heading replaced, its tilt kept — the same rule a piece of scenery is turned by. + Quaternion? facing = yawDegrees is { } yaw + ? Assets.Frames.FrameTransplant.WithHeading(actor.Rotation, yaw * MathF.PI / 180f) + : null; if (ImportPlaced(actorsRow, frameRow, theirPack, actor, newName, at, facing, carried, out reason) is not { Source: ActorNodeAdapter placed }) { @@ -269,21 +284,16 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO { var frame = (FrameObjectBase)named[occurrence - 1]; - // Turned and scaled as it was — the matrix an actor gives it folded in, since a prototype's own - // is the origin — and STANDING where it is asked to: the middle of the bottom of its geometry - // lands on the point, since a district mesh's origin can be anywhere in or around it. - Matrix4x4 world = frame.WorldTransform * theirPlacements.For(frame); - Matrix4x4 sourceWorld = world; - if (facing is { } heading) - { - Formats.Mathematics.MatrixExtensions.TryDecomposeRS(world, out Vector3 scale, out _, out _); - world = Formats.Mathematics.MatrixExtensions.SetMatrix(heading, scale, Vector3.Zero); - } - Vector3 standsOn = Assets.Frames.FrameTransplant.BaseOf(frame) ?? Vector3.Zero; - world.Translation = at - Vector3.TransformNormal(standsOn, world); + // Tilted and scaled as it was — the matrix an actor gives it folded in, since a prototype's own + // is the origin — facing the way it did or the way it is asked to, and STANDING where it is + // asked to: the middle of the bottom of its geometry lands on the point, since a district + // mesh's origin can be anywhere in or around it. + Matrix4x4 sourceWorld = frame.WorldTransform * theirPlacements.For(frame); + Matrix4x4 world = Assets.Frames.FrameTransplant.StandAt(frame, sourceWorld, at, + yawDegrees is { } turn ? turn * MathF.PI / 180f : null); carried = Assets.Frames.FrameTransplant.TryTransplant(scene, theirs, frame, newName, - Assets.Frames.FrameTransplant.Standing.Scenery, world, shared, out reason); + Assets.Frames.FrameTransplant.Standing.Scenery, world, shared, parent, out reason); if (carried == null) return reason ?? "the object could not be copied"; carry = Carry(carried); ImportScenery(frameRow, carried, @@ -520,9 +530,15 @@ private Rows BuildRows(SceneNode frameRow, TransplantedObject carried) } // A scenery object hangs under its scene's row, as the loader would put it; a prototype is a true - // top-level frame and hangs under the FrameResource row itself. + // top-level frame and hangs under the FrameResource row itself; one that was hung under a frame is + // under that frame's row. SceneNode parent = frameRow; - if (carried.Anchor != null) + if (carried.Under != null) + { + parent = AllNodes().FirstOrDefault( + n => n.Source is FrameNodeAdapter held && ReferenceEquals(held.Frame, carried.Under)) ?? frameRow; + } + else if (carried.Anchor != null) { parent = frameRow.Children.FirstOrDefault( c => c.Source is FrameSceneAdapter scene && ReferenceEquals(scene.Scene, carried.Anchor)) ?? frameRow; diff --git a/src/Illusion/Viewport/ZoneWrites.cs b/src/Illusion/Viewport/ZoneWrites.cs index 5f7652c..f10f130 100644 --- a/src/Illusion/Viewport/ZoneWrites.cs +++ b/src/Illusion/Viewport/ZoneWrites.cs @@ -104,6 +104,31 @@ private static IEnumerable Holding(FileInfo archive) : null; } + /// Why an archive's working copy cannot be written from outside the editors now, or null: one of + /// them holds it as a document, or is still loading it, and would write its own scene over the change. + public static string? HeldByAnEditor(FileInfo archive) + { + string name = Path.GetFileNameWithoutExtension(archive.Name); + if (Viewports().Any(host => host.Streamer.IsLoading(archive))) return $"{name} is still being loaded into an editor - try again when it is in"; + return OpenArchives.HoldersOf(archive).Count > 0 ? $"{name} is open in an editor - close it there first" : null; + } + + /// + /// After an archive's working copy was written from outside the editors: the map editor queues it for a + /// Build, and for city_univers reads its Loading zones layer again. + /// + public static void Written(FileInfo archive) + { + bool main = string.Equals(archive.FullName, new FileInfo(Assets.MafiaEnvironment.CityUniversSds).FullName, StringComparison.OrdinalIgnoreCase); + foreach (D3DImageHost host in Viewports()) + { + if (!host.IsMapViewport) continue; + host.MarkArchiveModified(archive); + if (main) host.Catalogs.ReloadZones(); + host.RaiseDirtyChanged(); + } + } + /// /// After was saved with changed: every editor holding the /// archive gets the zone as it now is, and every viewport queues the archive for a Build and reads its