From ac360bf3528ebba032b94926515712f953883a9d Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Fri, 2 Oct 2026 02:09:07 +0300 Subject: [PATCH 001/102] feat: preview a material on the context mesh slot in the Material Editor Local work found uncommitted in the working copy (base a55dc22, v0.3.1): sphere/mesh preview toggle, the slot label on the assign panel, and the SDK pin moved to 10.0.302. --- .../Viewport/MaterialPreviewViewport.cs | 92 ++++++++++++++++++- src/Illusion/Views/EditorStyles.xaml | 32 +++++++ src/Illusion/Views/MaterialEditorWindow.xaml | 35 +++++-- .../Views/MaterialEditorWindow.xaml.cs | 49 +++++++++- 4 files changed, 195 insertions(+), 13 deletions(-) diff --git a/src/Illusion/Viewport/MaterialPreviewViewport.cs b/src/Illusion/Viewport/MaterialPreviewViewport.cs index 3b2bc38..d8d6b98 100644 --- a/src/Illusion/Viewport/MaterialPreviewViewport.cs +++ b/src/Illusion/Viewport/MaterialPreviewViewport.cs @@ -26,6 +26,18 @@ public sealed class MaterialPreviewViewport : ViewportControl private bool _orbiting; private Point _lastOrbit; + // Mesh-shape preview: raw pick geometry (positions + indices) from a scene node. + // When set and UseMesh is true, the preview shows this geometry instead of the sphere. + private Vector3[]? _meshPositions; + private uint[]? _meshIndices; + private bool _useMesh; + + public bool UseMesh + { + get => _useMesh; + set { _useMesh = value; Rebuild(); } + } + public MaterialPreviewViewport() { ShowSky = true; // gradient sky, or the game panorama once the owner calls LoadSky @@ -65,7 +77,7 @@ public void SetFolders(IReadOnlyList folders) Rebuild(); } - /// Shows a material: its three maps on the sphere + its own specular/fresnel response. + /// Shows a material: its three maps on the sphere (or the current mesh geometry) + its own specular/fresnel response. public void SetMaterial(ulong hash, string? diffuse, string? normal, string? specular, LightingConstants lighting) { // The tint is part of the material's look, not a decoration: a car body has no albedo at all and @@ -77,6 +89,17 @@ public void SetMaterial(ulong hash, string? diffuse, string? normal, string? spe Rebuild(); } + /// + /// Provides the raw pick geometry (subset of a mesh's PickPositions/PickIndices for one material slot) + /// that will be shown when is true. Pass null to clear and fall back to the sphere. + /// + public void SetMeshGeometry(Vector3[]? positions, uint[]? indices) + { + _meshPositions = positions; + _meshIndices = indices; + if (_useMesh) Rebuild(); + } + protected override void OnSceneInitialized() { Renderer!.Camera.LookAt(new Vector3(0f, -2.6f, 1.0f), Vector3.Zero); @@ -106,7 +129,72 @@ private void Rebuild() // Whole-mirror fallback: the preview shows real textures even for unloaded districts. Renderer.Textures.SetFallbackResolver(Assets.Textures.TextureSearchIndex.FindPath); Renderer.Clear(); - Renderer.AddMesh(SphereMesh.Create(_part)); + MeshData mesh = _useMesh && _meshPositions != null && _meshIndices is { Length: >= 3 } + ? BuildFlatMesh(_meshPositions, _meshIndices, _part) + : SphereMesh.Create(_part); + Renderer.AddMesh(mesh); + } + + /// + /// Builds a flat-shaded from raw pick geometry for the mesh-shape preview. + /// Positions are centred and normalised to fit a unit sphere; face normals are computed analytically + /// (per-triangle flat shading). UVs and tangents are zeroed — the shader skips normal-map sampling + /// for a zero tangent, giving a clean diffuse-only silhouette at unit-UV (0,0). + /// + private static MeshData BuildFlatMesh(Vector3[] allPositions, uint[] indices, MeshPart part) + { + // Compute AABB over the referenced positions to centre + normalise. + var bmin = new Vector3(float.MaxValue); + var bmax = new Vector3(float.MinValue); + for (int k = 0; k < indices.Length; k++) + { + Vector3 p = allPositions[indices[k]]; + bmin = Vector3.Min(bmin, p); + bmax = Vector3.Max(bmax, p); + } + Vector3 center = (bmin + bmax) * 0.5f; + float extent = (bmax - bmin).Length() * 0.5f; + float scale = extent > 0.0001f ? 1f / extent : 1f; + + // Fan out indexed geometry: each triangle becomes 3 unique vertices with a shared face normal. + int triCount = indices.Length / 3; + var positions = new Vector3[triCount * 3]; + var normals = new Vector3[triCount * 3]; + var uvs = new Vector2[triCount * 3]; // zeroed — UV(0,0) samples the texture centre + var tangents = new Vector3[triCount * 3]; // zeroed → shader skips normal-map sampling + var outIdx = new uint[triCount * 3]; + + for (int t = 0; t < triCount; t++) + { + Vector3 p0 = (allPositions[indices[t * 3 ]] - center) * scale; + Vector3 p1 = (allPositions[indices[t * 3 + 1]] - center) * scale; + Vector3 p2 = (allPositions[indices[t * 3 + 2]] - center) * scale; + + Vector3 cross = Vector3.Cross(p1 - p0, p2 - p0); + Vector3 n = cross.LengthSquared() > 1e-10f ? Vector3.Normalize(cross) : Vector3.UnitZ; + + int vi = t * 3; + positions[vi] = p0; positions[vi + 1] = p1; positions[vi + 2] = p2; + normals[vi] = normals[vi + 1] = normals[vi + 2] = n; + outIdx[vi] = (uint)vi; outIdx[vi + 1] = (uint)(vi + 1); outIdx[vi + 2] = (uint)(vi + 2); + } + + return new MeshData + { + Name = "mesh-shape-preview", + World = Matrix4x4.Identity, + Positions = positions, + Normals = normals, + UVs = uvs, + Tangents = tangents, + Binormals = tangents, // also zero — same fallback in the shader + Indices = outIdx, + Parts = new[] + { + new MeshPart(0, outIdx.Length, + part.DiffuseTexture, part.NormalTexture, part.SpecularTexture, part.MaterialHash), + }, + }; } /// diff --git a/src/Illusion/Views/EditorStyles.xaml b/src/Illusion/Views/EditorStyles.xaml index 80257b2..8f06db5 100644 --- a/src/Illusion/Views/EditorStyles.xaml +++ b/src/Illusion/Views/EditorStyles.xaml @@ -134,6 +134,38 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs new file mode 100644 index 0000000..ae13c53 --- /dev/null +++ b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs @@ -0,0 +1,168 @@ +using System.Windows; +using System.Windows.Controls; +using System.Windows.Input; +using System.Windows.Threading; +using Illusion.Assets.Library; +using Illusion.ViewModels; +using Illusion.Viewport; + +namespace Illusion.Views; + +/// +/// The Props tab: the stock objects finds in the extracted archives, as tiles to +/// drag into the scene. +/// +/// The catalog is read in the background the first time the tab is shown (it is remembered on disk after +/// that). Pictures come afterwards, one per tick of the UI thread — the kept ones first, then the missing +/// ones drawn in archive order, so an archive is read once for all of its tiles. Placing is not this tab's +/// business: it raises , and the window that knows which district is loaded +/// answers it. +/// +/// +public partial class PropsTabView : UserControl +{ + /// The format the tiles are dragged in — what the viewport accepts as a drop. + public const string DragFormat = "Illusion.Prop"; + + private const string AllShelves = "All"; + + private readonly List _all = []; + private readonly PropThumbnailRenderer _thumbnails = new(); + private readonly DispatcherTimer _pictures; + private bool _loading; + private bool _loaded; + private Point _pressedAt; + private PropTileViewModel? _pressed; + + public PropsTabView() + { + InitializeComponent(); + CategoryBox.Items.Add(AllShelves); + foreach ((string name, _) in PropCatalog.Categories) CategoryBox.Items.Add(name); + CategoryBox.Items.Add("Other"); + CategoryBox.SelectedIndex = 0; + + _pictures = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(15) }; + _pictures.Tick += (_, _) => DrawNextPicture(); + IsVisibleChanged += (_, _) => + { + if (IsVisible) LoadCatalog(force: false); + else _pictures.Stop(); + }; + Unloaded += (_, _) => _thumbnails.Dispose(); + } + + /// A tile was dropped on the viewport (with the drop point) or double-clicked (no point: in front + /// of the camera). The window places the object. + public event Action? PlaceRequested; + + private void LoadCatalog(bool force) + { + if (_loading || (_loaded && !force)) return; + _loading = true; + Status.Text = "Reading the archives…"; + Task.Run(() => PropCatalog.Load((archive, at, of) => + Dispatcher.BeginInvoke(() => Status.Text = $"Reading {archive} ({at} of {of})…"))) + .ContinueWith(task => Dispatcher.BeginInvoke(() => + { + _loading = false; + if (task.IsFaulted) + { + Status.Text = "The archives could not be read: " + task.Exception?.GetBaseException().Message; + return; + } + _loaded = true; + _all.Clear(); + foreach (PropEntry entry in task.Result) _all.Add(new PropTileViewModel(entry)); + ApplyFilter(); + }), TaskScheduler.Default); + } + + private void Filter_Changed(object sender, RoutedEventArgs e) + { + if (IsInitialized) ApplyFilter(); + } + + private void ApplyFilter() + { + string query = SearchBox.Text.Trim(); + string shelf = CategoryBox.SelectedItem as string ?? AllShelves; + List shown = _all.Where(t => + (shelf == AllShelves || t.Entry.Category == shelf) + && (query.Length == 0 + || t.Entry.Label.Contains(query, StringComparison.OrdinalIgnoreCase) + || t.Entry.Archive.Contains(query, StringComparison.OrdinalIgnoreCase) + || t.Entry.Category.Contains(query, StringComparison.OrdinalIgnoreCase))) + .ToList(); + Tiles.ItemsSource = shown; + if (_loaded) + { + Status.Text = $"{shown.Count} of {_all.Count} objects · drag one onto the viewport, or double-click it"; + } + if (shown.Count > 0) _pictures.Start(); + } + + // One picture per tick: a kept one if any tile shown still lacks it, otherwise one drawn — taking the + // archive the renderer already holds first, so archives are read once rather than once per tile. + private void DrawNextPicture() + { + if (Tiles.ItemsSource is not List shown || !IsVisible) + { + _pictures.Stop(); + return; + } + List missing = shown.Where(t => t.Thumbnail == null && !t.ThumbnailTried).ToList(); + if (missing.Count == 0) + { + _pictures.Stop(); + return; + } + + foreach (PropTileViewModel tile in missing.Take(24)) + { + if (PropThumbnailRenderer.Cached(tile.Entry) is { } kept) + { + tile.Thumbnail = kept; + tile.ThumbnailTried = true; + return; + } + } + + PropTileViewModel next = missing.OrderBy(t => t.Entry.Archive, StringComparer.OrdinalIgnoreCase).First(); + next.ThumbnailTried = true; + next.Thumbnail = _thumbnails.Render(next.Entry); + } + + private void Rescan_Click(object sender, RoutedEventArgs e) => LoadCatalog(force: true); + + private void Tiles_MouseDoubleClick(object sender, MouseButtonEventArgs e) + { + if (TileUnder(e.OriginalSource) is { } tile) PlaceRequested?.Invoke(tile.Entry, null); + } + + private void Tiles_PreviewMouseLeftButtonDown(object sender, MouseButtonEventArgs e) + { + _pressed = TileUnder(e.OriginalSource); + _pressedAt = e.GetPosition(this); + } + + // A drag starts once the press has travelled the system's drag distance — a click stays a click. + private void Tiles_PreviewMouseMove(object sender, MouseEventArgs e) + { + if (_pressed == null || e.LeftButton != MouseButtonState.Pressed) return; + Vector moved = e.GetPosition(this) - _pressedAt; + if (Math.Abs(moved.X) < SystemParameters.MinimumHorizontalDragDistance + && Math.Abs(moved.Y) < SystemParameters.MinimumVerticalDragDistance) + { + return; + } + PropTileViewModel dragged = _pressed; + _pressed = null; + DragDrop.DoDragDrop(Tiles, new DataObject(DragFormat, dragged.Entry), DragDropEffects.Copy); + } + + private static PropTileViewModel? TileUnder(object source) => + source is FrameworkElement { DataContext: PropTileViewModel tile } ? tile + : source is FrameworkContentElement { DataContext: PropTileViewModel content } ? content + : null; +} From 4282d4b2a5d13e7ce383c3bdafdc26cc9ee54623 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:49:37 +0300 Subject: [PATCH 023/102] feat: simplified hulls for imported scenery, and a props column - A hull cooked for scenery that had none of its own is now its convex hull by default (ConvexHull: incremental, points snapped to a grid a fortieth of the object's size) - a coat rack goes from 1474 collision triangles to 22. Also a box (12 triangles) or every render triangle, chosen per import: object_import's 'collision' (auto / convex / box / mesh / none) and a chooser in the props panel. A flat thing has no convex hull and gets a box. - The props panel moves out of the property rail into a column of its own left of the viewport: as tall as the window, sized with a splitter, shown and hidden by a Props button on the toolbar; the viewport's tool shelf moves with the viewport and is no longer covered. - Textures an import carries are written down beside the working copy (illusion_carried.json, outside the manifest) and each save sweeps the ones no material of the scene names any more - an undone import, or one lost with a scene closed unsaved, no longer leaves megabytes behind. --probe-object-transplant: 33/0 (+ the hull of a cube is the cube, a stock model's hull holds all its vertices and faces out, a flat thing gets a box). --- README.md | 7 +- .../Adapters/SceneDocumentAdapter.cs | 3 + .../Collisions/CollisionCarry.cs | 22 +- .../Collisions/CollisionChoice.cs | 20 ++ src/Illusion.Assets/Collisions/ConvexHull.cs | 215 ++++++++++++++++++ src/Illusion.Assets/Collisions/HullShape.cs | 16 ++ src/Illusion.Assets/Sds/ArchiveCarry.cs | 90 +++++++- src/Illusion.Mcp/IEditorSession.cs | 3 +- src/Illusion.Mcp/Tools/EditorTools.cs | 5 +- .../Probes/ObjectTransplantProbes.cs | 44 ++++ src/Illusion/Mcp/AppEditorSession.cs | 9 +- .../Viewport/ObjectImportController.cs | 36 ++- src/Illusion/Views/MainWindow.xaml | 24 +- src/Illusion/Views/MainWindow.xaml.cs | 17 +- .../Views/ScenePanel/ScenePanel.xaml.cs | 3 - .../Views/ScenePanel/ScenePropertyTabs.xaml | 9 - .../ScenePanel/ScenePropertyTabs.xaml.cs | 15 +- .../Views/ScenePanel/Tabs/PropsTabView.xaml | 20 +- .../ScenePanel/Tabs/PropsTabView.xaml.cs | 7 + 19 files changed, 509 insertions(+), 56 deletions(-) create mode 100644 src/Illusion.Assets/Collisions/CollisionChoice.cs create mode 100644 src/Illusion.Assets/Collisions/ConvexHull.cs create mode 100644 src/Illusion.Assets/Collisions/HullShape.cs diff --git a/README.md b/README.md index de61380..6213759 100644 --- a/README.md +++ b/README.md @@ -160,10 +160,10 @@ The camera has two modes, switched by the top button of the viewport tool shelf district's scene. Geometry is copied into the district's own pools and the textures its materials name into its working copy, so nothing depends on the source archive being loaded. `--probe-object-transplant` carries a door, a prop and a piece of scenery onto a scratch copy and reads them back. -- **Props tab** (map editor): the stock game's doors, seating, tables, beds, storage, plants, lamps and decor +- **Props panel** (map editor, left of the viewport, toolbar's Props button): the stock game's doors, seating, tables, beds, storage, plants, lamps and decor from every extracted interior and district, one card per object with a picture. Drag a card onto the viewport to put the object where it lands, or double-click it to put it in front of the camera. Scenery - arrives with its own collision hulls, or one cooked from its triangles when it had none; a door is made + arrives with its own collision hulls, or — when it had none — its convex hull (or a box, every triangle, or nothing, from the panel's Collision chooser); a door is made openable by the player even where its own building's script used to do that. The scan is remembered between runs (`--probe-prop-catalog`). @@ -270,8 +270,7 @@ inside is not decoded, and the responses say so rather than looking complete. - The Resource Editor tile is a stub. - Duplicating frame objects covers static single-mesh objects only. -- Objects carried from another archive: skinned models cannot travel yet, a cooked hull follows the - render triangles rather than a simplified shape, and each import copies +- Objects carried from another archive: skinned models cannot travel yet, each import copies its geometry — import once and duplicate inside the district rather than importing the same thing twice. - A topology rebuild does not regenerate lower LODs or collision for that object. - `.sds.patch`, `.tra` and `cityareas.bin` are read-only. `StreamMap*.bin` is read-only in the diff --git a/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs b/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs index e411523..f407a71 100644 --- a/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs +++ b/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs @@ -209,6 +209,9 @@ public string SaveWorkingCopy() SdsWriter.SaveFrameNameTable(_frame, SourceArchive); _nameTableDirty = false; } + // Textures an import carried in that the scene no longer names — an undone import, or one a scene + // closed without saving took with it — leave with this save. + Sds.ArchiveCarry.SweepUnused(extracted, _frame); } // The pools describe geometry the FRAME RESOURCE indexes, so they go only when it went. A refused car diff --git a/src/Illusion.Assets/Collisions/CollisionCarry.cs b/src/Illusion.Assets/Collisions/CollisionCarry.cs index d95d36a..54f1406 100644 --- a/src/Illusion.Assets/Collisions/CollisionCarry.cs +++ b/src/Illusion.Assets/Collisions/CollisionCarry.cs @@ -89,11 +89,12 @@ public static IReadOnlyList FromSource(CollisionFile source, CollisionFile /// /// A hull cooked from render triangles given in WORLD space, placed at 's - /// position and heading (a placement cannot carry a scale, so the geometry is cooked at the size it is). + /// position and heading (a placement cannot carry a scale, so the geometry is cooked at the size it is), + /// shaped as says — the triangles themselves, their convex hull, or their box. /// Null with a reason when the cooker is not there or refuses the geometry. /// public static Hull? FromGeometry(CollisionFile target, IReadOnlyList<(Vector3[] Positions, uint[] Indices)> meshes, - Matrix4x4 targetWorld, byte group, out string? reason) + Matrix4x4 targetWorld, byte group, HullShape shape, out string? reason) { ArgumentNullException.ThrowIfNull(target); ArgumentNullException.ThrowIfNull(meshes); @@ -130,6 +131,23 @@ public static IReadOnlyList FromSource(CollisionFile source, CollisionFile return null; } + // A stand-in rather than the model: the hull of every point the triangles use, or the box around them. + // A thin or flat object has no volume to hull and gets its box. + if (shape != HullShape.Mesh) + { + List used = [.. indices.Distinct().Select(i => positions[i])]; + (Vector3[] Vertices, int[] Triangles)? simple = shape == HullShape.Convex + ? ConvexHull.Build(used) ?? ConvexHull.Box(used) + : ConvexHull.Box(used); + if (simple is not { } hull) + { + reason = "the object has no volume to make a hull of"; + return null; + } + positions = [.. hull.Vertices]; + indices = [.. hull.Triangles]; + } + ushort[] surfaces = Enumerable.Repeat(UniversalHard, indices.Count / 3).ToArray(); CollisionSectionPlan? plan = CollisionSectionBuilder.TryBuild([.. indices], surfaces, out string? planRefusal); if (plan == null) diff --git a/src/Illusion.Assets/Collisions/CollisionChoice.cs b/src/Illusion.Assets/Collisions/CollisionChoice.cs new file mode 100644 index 0000000..9dc6db4 --- /dev/null +++ b/src/Illusion.Assets/Collisions/CollisionChoice.cs @@ -0,0 +1,20 @@ +namespace Illusion.Assets.Collisions; + +/// What collision a piece of scenery brings when it is carried into a district. +public enum CollisionChoice +{ + /// Its own hulls from the source archive, or — when it had none — its convex hull. + Auto, + + /// Always its convex hull, cooked here, even when the source has hulls of its own. + Convex, + + /// Always the box it fits in. + Box, + + /// Every render triangle: exact, and as heavy as the model. + Mesh, + + /// None — something to walk through, or to give a hull of its own later. + None, +} diff --git a/src/Illusion.Assets/Collisions/ConvexHull.cs b/src/Illusion.Assets/Collisions/ConvexHull.cs new file mode 100644 index 0000000..b6b2e3e --- /dev/null +++ b/src/Illusion.Assets/Collisions/ConvexHull.cs @@ -0,0 +1,215 @@ +using System.Numerics; + +namespace Illusion.Assets.Collisions; + +/// +/// The convex hull of a point cloud, as triangles wound outward — what a simplified collision hull is cooked +/// from. +/// +/// Incremental: a starting tetrahedron, then every point outside the current hull replaces the faces it can +/// see with a fan to their horizon. The points are first snapped to a grid a fortieth of the object's size, +/// which is what keeps the result to dozens of triangles rather than hundreds: a hull is a stand-in for the +/// object, and detail finer than that is detail a player's capsule never feels. Arithmetic in doubles, with a +/// tolerance scaled to the object, so coplanar faces do not tear the hull. +/// +/// +public static class ConvexHull +{ + // How finely the points are snapped, as a fraction of the object's largest extent. + private const double GridFraction = 1.0 / 40.0; + + /// + /// The hull's vertices and its triangles (three indices each, wound so the normal points out). Null when + /// the points do not span a volume — a flat or a thin object has no convex hull, only a box. + /// + public static (Vector3[] Vertices, int[] Triangles)? Build(IReadOnlyList points) + { + ArgumentNullException.ThrowIfNull(points); + if (points.Count < 4) return null; + + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + foreach (Vector3 p in points) + { + min = Vector3.Min(min, p); + max = Vector3.Max(max, p); + } + double extent = Math.Max(max.X - min.X, Math.Max(max.Y - min.Y, max.Z - min.Z)); + if (extent <= 1e-6) return null; + double cell = extent * GridFraction; + double eps = extent * 1e-6; + + // Snapped and unique. + var unique = new Dictionary<(long, long, long), V>(); + foreach (Vector3 p in points) + { + var key = ((long)Math.Round(p.X / cell), (long)Math.Round(p.Y / cell), (long)Math.Round(p.Z / cell)); + unique.TryAdd(key, new V(key.Item1 * cell, key.Item2 * cell, key.Item3 * cell)); + } + List pts = [.. unique.Values]; + if (pts.Count < 4) return null; + + if (!Tetrahedron(pts, eps, out int i0, out int i1, out int i2, out int i3)) return null; + V centre = (pts[i0] + pts[i1] + pts[i2] + pts[i3]) * 0.25; + var faces = new List(); + void AddFace(int a, int b, int c) + { + var f = new Face(a, b, c, pts); + if (f.Distance(centre) > 0) f = new Face(a, c, b, pts); // the inside must be behind every face + faces.Add(f); + } + AddFace(i0, i1, i2); + AddFace(i0, i1, i3); + AddFace(i0, i2, i3); + AddFace(i1, i2, i3); + + for (int p = 0; p < pts.Count; p++) + { + if (p == i0 || p == i1 || p == i2 || p == i3) continue; + var visible = faces.Where(f => f.Distance(pts[p]) > eps).ToList(); + if (visible.Count == 0) continue; // inside + + // The horizon: edges of the visible faces whose other side is not visible. + var edges = new HashSet<(int, int)>(); + foreach (Face f in visible) + { + foreach ((int u, int v) in f.Edges) edges.Add((u, v)); + } + var horizon = edges.Where(e => !edges.Contains((e.Item2, e.Item1))).ToList(); + foreach (Face f in visible) faces.Remove(f); + foreach ((int u, int v) in horizon) + { + // Same winding as the face it replaces, so the new face faces out as well. + faces.Add(new Face(u, v, p, pts)); + } + } + + // Only the points the hull uses, renumbered. + var remap = new Dictionary(); + var vertices = new List(); + var triangles = new List(faces.Count * 3); + foreach (Face f in faces) + { + foreach (int i in new[] { f.A, f.B, f.C }) + { + if (!remap.TryGetValue(i, out int j)) + { + remap[i] = j = vertices.Count; + vertices.Add(new Vector3((float)pts[i].X, (float)pts[i].Y, (float)pts[i].Z)); + } + triangles.Add(j); + } + } + return ([.. vertices], [.. triangles]); + } + + /// The box the points fit in, axis-aligned in their own space: 8 corners, 12 outward triangles. + public static (Vector3[] Vertices, int[] Triangles)? Box(IReadOnlyList points) + { + ArgumentNullException.ThrowIfNull(points); + if (points.Count == 0) return null; + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + foreach (Vector3 p in points) + { + min = Vector3.Min(min, p); + max = Vector3.Max(max, p); + } + // A flat object (a rug, a sign) still gets something a capsule can stand on or bump into. + Vector3 thin = Vector3.Max(max - min, new Vector3(0.02f)) - (max - min); + min -= thin / 2; + max += thin / 2; + Vector3[] v = + [ + new(min.X, min.Y, min.Z), new(max.X, min.Y, min.Z), new(max.X, max.Y, min.Z), new(min.X, max.Y, min.Z), + new(min.X, min.Y, max.Z), new(max.X, min.Y, max.Z), new(max.X, max.Y, max.Z), new(min.X, max.Y, max.Z), + ]; + int[] t = + [ + 0, 2, 1, 0, 3, 2, // bottom (−Z) + 4, 5, 6, 4, 6, 7, // top (+Z) + 0, 1, 5, 0, 5, 4, // −Y + 2, 3, 7, 2, 7, 6, // +Y + 1, 2, 6, 1, 6, 5, // +X + 3, 0, 4, 3, 4, 7, // −X + ]; + return (v, t); + } + + // Four points that span a volume: the two farthest apart along an axis, the one farthest from their line, + // the one farthest from the plane of the three. + private static bool Tetrahedron(List pts, double eps, out int a, out int b, out int c, out int d) + { + a = b = c = d = -1; + double best = -1; + for (int axis = 0; axis < 3; axis++) + { + int lo = 0, hi = 0; + for (int i = 1; i < pts.Count; i++) + { + if (pts[i][axis] < pts[lo][axis]) lo = i; + if (pts[i][axis] > pts[hi][axis]) hi = i; + } + double span = (pts[hi] - pts[lo]).Length; + if (span > best) { best = span; a = lo; b = hi; } + } + if (best <= eps) return false; + + V ab = pts[b] - pts[a]; + best = -1; + for (int i = 0; i < pts.Count; i++) + { + double dist = V.Cross(ab, pts[i] - pts[a]).Length; + if (dist > best) { best = dist; c = i; } + } + if (best <= eps) return false; + + V n = V.Cross(ab, pts[c] - pts[a]).Normalized; + best = -1; + for (int i = 0; i < pts.Count; i++) + { + double dist = Math.Abs(V.Dot(n, pts[i] - pts[a])); + if (dist > best) { best = dist; d = i; } + } + return best > eps; + } + + private sealed class Face + { + public readonly int A, B, C; + private readonly V _normal; + private readonly double _offset; + + public Face(int a, int b, int c, List pts) + { + A = a; + B = b; + C = c; + _normal = V.Cross(pts[b] - pts[a], pts[c] - pts[a]).Normalized; + _offset = V.Dot(_normal, pts[a]); + } + + /// Signed distance of a point in front of the face (positive outside). + public double Distance(V p) => V.Dot(_normal, p) - _offset; + + public IEnumerable<(int, int)> Edges + { + get + { + yield return (A, B); + yield return (B, C); + yield return (C, A); + } + } + } + + private readonly record struct V(double X, double Y, double Z) + { + public double this[int axis] => axis == 0 ? X : axis == 1 ? Y : Z; + public double Length => Math.Sqrt(X * X + Y * Y + Z * Z); + public V Normalized => Length > 0 ? this * (1 / Length) : this; + public static V operator -(V a, V b) => new(a.X - b.X, a.Y - b.Y, a.Z - b.Z); + public static V operator +(V a, V b) => new(a.X + b.X, a.Y + b.Y, a.Z + b.Z); + public static V operator *(V a, double s) => new(a.X * s, a.Y * s, a.Z * s); + public static double Dot(V a, V b) => a.X * b.X + a.Y * b.Y + a.Z * b.Z; + public static V Cross(V a, V b) => new(a.Y * b.Z - a.Z * b.Y, a.Z * b.X - a.X * b.Z, a.X * b.Y - a.Y * b.X); + } +} diff --git a/src/Illusion.Assets/Collisions/HullShape.cs b/src/Illusion.Assets/Collisions/HullShape.cs new file mode 100644 index 0000000..7d4ffff --- /dev/null +++ b/src/Illusion.Assets/Collisions/HullShape.cs @@ -0,0 +1,16 @@ +namespace Illusion.Assets.Collisions; + +/// What a hull cooked for an object that had none of its own is shaped like. +public enum HullShape +{ + /// The object's convex hull — what it would be shrink-wrapped in: a few dozen triangles that + /// keep its outline. The default: it is what stock props' own hulls mostly look like. + Convex, + + /// The box it fits in, turned with it: twelve triangles. The cheapest, for things that are + /// boxes anyway or that nobody will brush against. + Box, + + /// Every render triangle: exact, and as heavy as the model. + Mesh, +} diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index e68b51b..5ead2ad 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -20,9 +20,12 @@ namespace Illusion.Assets.Sds; /// left alone and shared. /// /// -/// This writes the working copy at once, not at Save. Carried resources are inert until something names -/// them, so an import that is later undone leaves a few unused files behind rather than a scene that names -/// resources a discarded edit took away. +/// This writes the working copy at once, not at Save — the viewport looks a texture up the moment it builds +/// a mesh. So an import that is undone, or a scene closed without saving, leaves textures nothing uses. Every +/// texture carried is therefore written down beside the working copy (), and each +/// save of the scene sweeps the ones no material of it names any more (). Only +/// what this class brought is ever swept: an archive's own textures may be named by things a scene does not +/// show (effects, decals, scripts) and are never touched. /// /// public static class ArchiveCarry @@ -87,6 +90,7 @@ private static void CarryTextures(SdsManifest from, SdsManifest to, string fromD } if (!CopyEntry(from, to, fromDir, toDir, texture)) continue; added.Add(texture); + Remember(toDir, texture); TextureSearchIndex.Register(Path.Combine(toDir, texture)); // A texture stored split keeps its top level in a companion entry, and its own entry says so — @@ -175,6 +179,86 @@ private static bool CarryPrefab(SdsManifest from, SdsManifest to, string fromDir return false; } + // Beside the working copy, never in its manifest: packing goes by the manifest, so the game never sees it. + private const string RegisterName = "illusion_carried.json"; + + private static HashSet ReadRegister(string dir) + { + string path = Path.Combine(dir, RegisterName); + try + { + return File.Exists(path) + ? new HashSet(System.Text.Json.JsonSerializer.Deserialize>(File.ReadAllText(path)) ?? [], + StringComparer.OrdinalIgnoreCase) + : new HashSet(StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException or UnauthorizedAccessException) + { + return new HashSet(StringComparer.OrdinalIgnoreCase); + } + } + + private static void WriteRegister(string dir, HashSet names) + { + string path = Path.Combine(dir, RegisterName); + if (names.Count == 0) + { + File.Delete(path); + return; + } + AtomicFile.WriteAllBytes(path, System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(names.Order(StringComparer.OrdinalIgnoreCase).ToList())); + } + + private static void Remember(string dir, string texture) + { + HashSet names = ReadRegister(dir); + if (names.Add(texture)) WriteRegister(dir, names); + } + + /// + /// Drops the textures this class carried into that no material of + /// names — left behind by an import that was undone, or by a scene closed without + /// saving it. Their MIP companions go with them. Returns what was dropped. + /// + public static IReadOnlyList SweepUnused(string extracted, Formats.Frames.FrameResource scene) + { + ArgumentException.ThrowIfNullOrEmpty(extracted); + ArgumentNullException.ThrowIfNull(scene); + HashSet carried = ReadRegister(extracted); + if (carried.Count == 0) return []; + + MafiaMaterials.EnsureLoaded(); + var named = new HashSet(StringComparer.OrdinalIgnoreCase); + foreach (Formats.Frames.Resources.FrameMaterial block in scene.FrameMaterials.Values) + { + foreach (Formats.Frames.Resources.MaterialStruct[] lod in block.Materials) + { + foreach (Formats.Frames.Resources.MaterialStruct slot in lod) + { + foreach (string texture in MafiaMaterials.Collection?.FindByHash(slot.MaterialHash)?.CollectTextures() ?? []) + { + named.Add(texture); + } + } + } + } + + SdsManifest manifest = SdsManifest.Load(extracted); + var dropped = new List(); + foreach (string texture in carried.ToList()) + { + if (named.Contains(texture)) continue; + foreach (string file in new[] { texture, SdsImportTypes.MipNameFor(texture) }) + { + if (manifest.RemoveEntry(file)) dropped.Add(file); + File.Delete(Path.Combine(extracted, file)); + } + carried.Remove(texture); + } + WriteRegister(extracted, carried); + return dropped; + } + /// /// Copies one file and the manifest entry that announces it, field for field — a packing handler reads /// an entry positionally, so the only safe way to move one is verbatim. diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index b84a0c7..1679c00 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -94,8 +94,9 @@ public interface IEditorSession /// 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. + /// For scenery: auto (its own hulls, else its convex hull), convex, box, mesh or none. string? ImportObject(string sourceArchive, string name, string newName, float[] position, float? yawDegrees, - out ObjectImportOutcome? outcome); + string? collision, out ObjectImportOutcome? outcome); /// Duplicates the selection (undoable) and leaves the copies selected. Null on success. string? DuplicateSelected(out IReadOnlyList copies); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 962af06..e599d72 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -509,14 +509,15 @@ 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, replacing the original's rotation. Omit to keep the rotation the original has.")] float? yawDegrees = null, + [Description("Collision for scenery: 'auto' (default — its own hulls from the source, else its convex hull), '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) { try { if (position.Length != 3) return ToolResult.Invalid("position takes three numbers"); ObjectImportOutcome? outcome = null; string? refused = await ui.RunAsync( - () => editor.ImportObject(sourceArchive, name, newName, position, yawDegrees, out outcome)); + () => editor.ImportObject(sourceArchive, name, newName, position, yawDegrees, collision, out outcome)); if (refused != null) return ToolResult.Invalid(refused); return ToolResult.Json(new { success = true, imported = outcome, status = await ui.RunAsync(editor.Status) }); } diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 27184be..1b99ffe 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -268,6 +268,25 @@ [.. all.SelectMany(t => t.VertexBuffers).Select(b => b.Hash)], first.Prefab && barePrefabs.Count == 1 && PrefabFile.Load(barePrefabs[0]).Contains(definition) && PrefabFile.Load(barePrefabs[0]).PrefabCount == 1 && carriedDoor.CollisionHashes.All(ArchiveCarry.ItemDescriptionsOf(bareAfter).ContainsKey)); + + // ── Simplified hulls: what a prop with no collision of its own is given ── + var cube = new List(); + for (int i = 0; i < 8; i++) cube.Add(new Vector3(i & 1, (i >> 1) & 1, (i >> 2) & 1)); + for (int i = 0; i < 50; i++) cube.Add(new Vector3(0.2f + i % 5 * 0.1f, 0.3f + i % 7 * 0.05f, 0.5f)); + (Vector3[] Vertices, int[] Triangles)? box = Assets.Collisions.ConvexHull.Build(cube); + Check("the hull of a cube with points inside it is the cube: 8 corners, 12 triangles", + box is { } cubeHull && cubeHull.Vertices.Length == 8 && cubeHull.Triangles.Length == 36 && Encloses(cubeHull, cube), + box == null ? "no hull" : $"{box.Value.Vertices.Length} vertices, {box.Value.Triangles.Length / 3} triangles"); + + List model = [.. FrameTransplant.TrianglesOf(scenery).SelectMany(m => m.Positions)]; + (Vector3[] Vertices, int[] Triangles)? hull = Assets.Collisions.ConvexHull.Build(model); + int modelTriangles = FrameTransplant.TrianglesOf(scenery).Sum(m => m.Indices.Length / 3); + Check("a stock model's convex hull holds every one of its vertices, faces out, and is a fraction of its size", + hull is { } modelHull && Encloses(modelHull, model, tolerance: 0.04f) && modelHull.Triangles.Length / 3 < Math.Max(200, modelTriangles / 2), + hull == null ? "no hull" : $"'{scenery.Name}': {modelTriangles} triangles → {hull.Value.Triangles.Length / 3}"); + 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 }); } catch (Exception ex) { @@ -368,6 +387,31 @@ private static void CheckShape(string what, TransplantedObject carried, FrameObj check($"{what} draws from buffers of its own that hold the original's bytes", badBuffers == 0, $"{buffers} LOD(s), {badBuffers} wrong"); } + // 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. + private static bool Encloses((Vector3[] Vertices, int[] Triangles) hull, IReadOnlyList points, float tolerance = 1e-4f) + { + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + foreach (Vector3 p in points) + { + min = Vector3.Min(min, p); + max = Vector3.Max(max, p); + } + float slack = tolerance * MathF.Max(max.X - min.X, MathF.Max(max.Y - min.Y, max.Z - min.Z)); + Vector3 middle = hull.Vertices.Aggregate(Vector3.Zero, (a, v) => a + v) / hull.Vertices.Length; + for (int t = 0; t < hull.Triangles.Length; t += 3) + { + Vector3 a = hull.Vertices[hull.Triangles[t]], b = hull.Vertices[hull.Triangles[t + 1]], c = hull.Vertices[hull.Triangles[t + 2]]; + Vector3 n = Vector3.Normalize(Vector3.Cross(b - a, c - a)); + if (Vector3.Dot(n, middle - a) > 0) return false; + foreach (Vector3 p in points) + { + if (Vector3.Dot(n, p - a) > slack) return false; + } + } + return true; + } + private static string CopyWithoutTextures(string from, string to) { Directory.CreateDirectory(to); diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index f40a940..6fa4996 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -404,7 +404,7 @@ public CameraInfo Camera() } public string? ImportObject(string sourceArchive, string name, string newName, float[] position, float? yawDegrees, - out ObjectImportOutcome? outcome) + string? collision, out ObjectImportOutcome? outcome) { outcome = null; if (Window is not { } window) return NotOpen; @@ -416,8 +416,13 @@ public CameraInfo Camera() } FileInfo destination = area.FileFor(window.WinterToggle.IsChecked == true); + 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"; + } return host.ObjectImporting.Import(destination, sourceArchive, name, newName, - new Vector3(position[0], position[1], position[2]), yawDegrees, out outcome); + new Vector3(position[0], position[1], position[2]), yawDegrees, out outcome, hulls); } public string? DuplicateSelected(out IReadOnlyList copies) diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index f2fbabc..9629545 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -88,8 +88,9 @@ public SceneNode ImportScenery(SceneNode frameRow, TransplantedObject carried, I /// /// 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. + /// What collision scenery brings (an actor's object always brings its own). public string? Import(FileInfo destination, string sourceArchive, string name, string newName, Vector3 at, - float? yawDegrees, out Mcp.ObjectImportOutcome? outcome) + float? yawDegrees, out Mcp.ObjectImportOutcome? outcome, Assets.Collisions.CollisionChoice hulls = default) { outcome = null; if (_host.BridgeEditedCount > 0) return "a Blender edit session is open — end it first"; @@ -192,7 +193,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO if (carried == null) return reason ?? "the object could not be copied"; carry = Carry(carried); ImportScenery(frameRow, carried, - SceneryCollision(destination, source, frame, sourceWorld, carried, out collision)); + SceneryCollision(destination, source, frame, sourceWorld, carried, hulls, out collision)); kind = "scenery"; } @@ -220,13 +221,19 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO /// /// The collision a piece of scenery brings: the source's hulls that stand inside its footprint, re-placed - /// by the same move that took the object to its new spot — or, when it had none of its own, one cooked - /// from its triangles. Returned as the edits that add them, for the import to apply and undo as one. + /// by the same move that took the object to its new spot — or one cooked here, as its convex hull, its box + /// or its triangles, when it had none of its own or asks for one. Returned as the + /// edits that add them, for the import to apply and undo as one. /// private IReadOnlyList SceneryCollision(FileInfo destination, Formats.Frames.ExtractedSds source, Formats.Frames.ObjectTypes.FrameObjectBase original, Matrix4x4 sourceWorld, - Assets.Frames.FrameTransplant.TransplantedObject carried, out string summary) + Assets.Frames.FrameTransplant.TransplantedObject carried, Assets.Collisions.CollisionChoice choice, out string summary) { + if (choice == Assets.Collisions.CollisionChoice.None) + { + summary = "none, as asked"; + return []; + } SceneNode? layer = AllNodes().FirstOrDefault(n => n.Source is Assets.Adapters.CollisionDocumentAdapter c && string.Equals(c.SourceArchive.FullName, destination.FullName, StringComparison.OrdinalIgnoreCase)); if (layer?.Source is not Assets.Adapters.CollisionDocumentAdapter document) @@ -238,7 +245,8 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO Matrix4x4 targetWorld = carried.Root.WorldTransform; var hulls = new List(); IReadOnlyList theirFiles = source.Manifest.GetFiles("Collisions"); - if (theirFiles.Count > 0 && Assets.Frames.FrameTransplant.BoundsOf(original) is { } bounds) + if (choice == Assets.Collisions.CollisionChoice.Auto && theirFiles.Count > 0 + && Assets.Frames.FrameTransplant.BoundsOf(original) is { } bounds) { // The footprint in the source's world: the root-space box through the object's own matrix. Vector3 min = new(float.MaxValue), max = new(float.MinValue); @@ -260,8 +268,14 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO byte group = document.Collision.Instances.Count == 0 ? (byte)0 : document.Collision.Instances.GroupBy(i => i.Group).OrderByDescending(g => g.Count()).First().Key; + Assets.Collisions.HullShape shape = choice switch + { + Assets.Collisions.CollisionChoice.Box => Assets.Collisions.HullShape.Box, + Assets.Collisions.CollisionChoice.Mesh => Assets.Collisions.HullShape.Mesh, + _ => Assets.Collisions.HullShape.Convex, + }; if (Assets.Collisions.CollisionCarry.FromGeometry(document.Collision, - Assets.Frames.FrameTransplant.TrianglesOf(carried.Root), targetWorld, group, out refusal) is { } cooked) + Assets.Frames.FrameTransplant.TrianglesOf(carried.Root), targetWorld, group, shape, out refusal) is { } cooked) { hulls.Add(cooked); } @@ -277,7 +291,13 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO ? "none — " + (refusal ?? "nothing to make one of") : hulls[0].FromSource ? $"{hulls.Count} hull(s) of its own from the source archive" - : "one cooked from its triangles (it had none of its own)"; + : choice switch + { + Assets.Collisions.CollisionChoice.Box => "its box, cooked here", + Assets.Collisions.CollisionChoice.Mesh => "one cooked from all its triangles", + Assets.Collisions.CollisionChoice.Convex => "its convex hull, cooked here", + _ => "its convex hull, cooked here (it had none of its own)", + }; return edits; } diff --git a/src/Illusion/Views/MainWindow.xaml b/src/Illusion/Views/MainWindow.xaml index bf07be6..7e15e8f 100644 --- a/src/Illusion/Views/MainWindow.xaml +++ b/src/Illusion/Views/MainWindow.xaml @@ -75,16 +75,25 @@ - + - + + + + + + + - + - diff --git a/src/Illusion/Views/MainWindow.xaml.cs b/src/Illusion/Views/MainWindow.xaml.cs index 5b2e8fa..6e88cae 100644 --- a/src/Illusion/Views/MainWindow.xaml.cs +++ b/src/Illusion/Views/MainWindow.xaml.cs @@ -117,7 +117,7 @@ public MainWindow() // Props: tiles of the Props tab are dragged onto the viewport (or double-clicked) to bring a stock // object into the loaded district. - Scene.EnableProps(PlaceProp); + PropsPanel.PlaceRequested += PlaceProp; Viewport.AllowDrop = true; Viewport.DragOver += (_, e) => { @@ -528,6 +528,19 @@ private void ShowViewportContextMenu(SceneNode? hit, Point pos) menu.IsOpen = true; } + // The Props button: the left column folds away to nothing and comes back at the width it had. + private double _propsWidth = 330; + + private void PropsToggle_Changed(object sender, RoutedEventArgs e) + { + if (!IsInitialized) return; + bool show = PropsToggle.IsChecked == true; + if (!show && PropsColumn.ActualWidth > 40) _propsWidth = PropsColumn.ActualWidth; + PropsColumn.Width = new GridLength(show ? _propsWidth : 0); + PropsPanel.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + PropsSplitter.Visibility = show ? Visibility.Visible : Visibility.Collapsed; + } + // A prop from the Props tab: carried into the loaded district (the season on screen), standing where the // drop landed on the scene — or, for a double-click, on whatever is in the middle of the view. Named after // what it is, made unique in the archive. @@ -557,7 +570,7 @@ private void PlaceProp(Assets.Library.PropEntry entry, Point? at) try { if (Viewport.ObjectImporting.Import(destination, entry.Archive, entry.Name, name, position, null, - out _) is { } refused) + out _, PropsPanel.Collision) is { } refused) { Viewport.RaiseNotice($"{entry.Label} not placed — {refused}", isError: true); } diff --git a/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs b/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs index 5f1f41f..6e9f6b9 100644 --- a/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs +++ b/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs @@ -225,9 +225,6 @@ public void ShowAssembly() /// public void HideCityFilters() => Tabs.RenderFilters.HideCityFilters(); - /// - public void EnableProps(Action place) => Tabs.EnableProps(place); - /// public void SetSnowFilter(bool on) => Tabs.RenderFilters.SetSnowFilter(on); diff --git a/src/Illusion/Views/ScenePanel/ScenePropertyTabs.xaml b/src/Illusion/Views/ScenePanel/ScenePropertyTabs.xaml index 24fbbfb..4e4ceb5 100644 --- a/src/Illusion/Views/ScenePanel/ScenePropertyTabs.xaml +++ b/src/Illusion/Views/ScenePanel/ScenePropertyTabs.xaml @@ -18,15 +18,6 @@ - - - - - - /// Turns the Props tab on — for the map editor, which has a district to put props in. A tile dropped on the - /// viewport or double-clicked is handed to . - /// - public void EnableProps(Action place) - { - PropsTab.Visibility = Visibility.Visible; - Props.PlaceRequested += place; - } - /// The Tuning tab, which is what an EntityDataStorage tile opens onto. public void ShowTuning() { @@ -66,11 +56,8 @@ public void SurfaceTabFor(SelectionViewModel selection) selection.IsScene ? SceneTab : selection.HasTypeProperties ? TypeTab : // type-only selections (e.g. a collision placement) surface their type tab RenderTab; - // The Props tab is kept too: placing several props in a row selects each one as it lands, and being - // thrown off the shelf after every drop would make that a chore. bool keepCurrent = PropertyTabs.SelectedItem is TabItem cur && cur.Visibility == Visibility.Visible - && (ReferenceEquals(cur, ObjectTab) || ReferenceEquals(cur, TypeTab) || ReferenceEquals(cur, MaterialsTab) - || ReferenceEquals(cur, PropsTab)); + && (ReferenceEquals(cur, ObjectTab) || ReferenceEquals(cur, TypeTab) || ReferenceEquals(cur, MaterialsTab)); if (!keepCurrent) target.IsSelected = true; // its Visibility binding has already made it visible } } diff --git a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml index 0f8a796..16fde54 100644 --- a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml +++ b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml @@ -8,6 +8,7 @@ + @@ -39,7 +40,24 @@ ToolTip="Scan the archives again (after extracting more of them)" Click="Rescan_Click" /> - + + + + + + + + + + + + + + + public event Action? PlaceRequested; + /// The collision the chooser above the tiles asks scenery to bring. + public Assets.Collisions.CollisionChoice Collision => + CollisionBox.SelectedItem is ComboBoxItem { Tag: string tag } + && Enum.TryParse(tag, out Assets.Collisions.CollisionChoice choice) + ? choice + : Assets.Collisions.CollisionChoice.Auto; + private void LoadCatalog(bool force) { if (_loading || (_loaded && !force)) return; From 9229e382ca53edd1e121f0a374d2f5839734ef4d Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 02:56:26 +0300 Subject: [PATCH 024/102] feat: an imported object's collision moves and is deleted with it Nothing in an archive ties a static object to its collision - the hull is a placement that happens to stand where the object stands. For scenery the toolkit carried in, the tie is now written down beside the working copy (ImportLinks, illusion_links.json, outside the manifest): the object's name and the hashes of the hulls it was given. A gizmo drag of the object drags those placements along (a scale mints a scaled hull as it does for any placement), and Delete takes them with it; a Delete that went through several editors is one Ctrl+Z (EditHistory.SquashSince). Stock objects are untouched: nothing ties them, and guessing by position would take a building's hull along with a bench. --- src/Illusion.Assets/Sds/ImportLinks.cs | 61 +++++++++++++++++++ src/Illusion.Domain/EditHistory.cs | 18 ++++++ src/Illusion/Viewport/D3DImageHost.cs | 38 ++++++++++++ .../Viewport/ObjectImportController.cs | 3 + .../Viewport/TransformEditController.cs | 13 ++++ 5 files changed, 133 insertions(+) create mode 100644 src/Illusion.Assets/Sds/ImportLinks.cs diff --git a/src/Illusion.Assets/Sds/ImportLinks.cs b/src/Illusion.Assets/Sds/ImportLinks.cs new file mode 100644 index 0000000..0ab780c --- /dev/null +++ b/src/Illusion.Assets/Sds/ImportLinks.cs @@ -0,0 +1,61 @@ +using System.Text.Json; + +namespace Illusion.Assets.Sds; + +/// +/// Which collision hulls a piece of scenery was given when it was carried into an archive — so the editor can +/// move and delete them WITH it. +/// +/// Nothing in the archive ties a static object to its collision: the hull is a placement in the collision +/// resource that happens to stand where the object stands. For the objects this toolkit carried in, the tie is +/// written down here, beside the working copy and outside its manifest (the game never sees it): the object's +/// name and the hashes of the hulls it was given. The placements themselves are found again by hash, the one +/// nearest the object — their positions change whenever the object is moved, their hulls do not. +/// +/// +public static class ImportLinks +{ + private const string FileName = "illusion_links.json"; + + /// Records the hulls an object was given, replacing whatever an earlier object of that name had. + public static void Set(string extractedDir, string frameName, IEnumerable hulls) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + ArgumentException.ThrowIfNullOrEmpty(frameName); + ArgumentNullException.ThrowIfNull(hulls); + Dictionary> links = Read(extractedDir); + List list = [.. hulls.Distinct()]; + if (list.Count == 0) links.Remove(frameName); + else links[frameName] = list; + try + { + AtomicFile.WriteAllBytes(Path.Combine(extractedDir, FileName), JsonSerializer.SerializeToUtf8Bytes(links)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // an unrecorded link costs moving the hull by hand, nothing more + } + } + + /// The hulls recorded for an object of this archive, or none. + public static IReadOnlyList HullsOf(string extractedDir, string frameName) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + return frameName is { Length: > 0 } && Read(extractedDir).TryGetValue(frameName, out List? hulls) ? hulls : []; + } + + private static Dictionary> Read(string extractedDir) + { + string path = Path.Combine(extractedDir, FileName); + try + { + return File.Exists(path) + ? JsonSerializer.Deserialize>>(File.ReadAllText(path)) ?? new() + : new(); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + return new(); + } + } +} diff --git a/src/Illusion.Domain/EditHistory.cs b/src/Illusion.Domain/EditHistory.cs index 62a68b7..a49deb3 100644 --- a/src/Illusion.Domain/EditHistory.cs +++ b/src/Illusion.Domain/EditHistory.cs @@ -51,6 +51,24 @@ private IEditAction[] TakeRedo() return dropped; } + /// How many edits can be undone — what is given to mark a point. + public int UndoCount => _undo.Count; + + /// + /// Folds every edit pushed since the undo stack held into one, built by + /// from them in the order they were pushed — so a command that went through + /// several editors is still one Ctrl+Z. Nothing happens when fewer than two were pushed. + /// + public void SquashSince(int count, Func combine) + { + ArgumentNullException.ThrowIfNull(combine); + if (count < 0 || _undo.Count - count < 2) return; + IEditAction[] edits = [.. _undo.Skip(count)]; + _undo.RemoveRange(count, edits.Length); + _undo.Add(combine(edits)); + Changed?.Invoke(); + } + /// Reverts the most recent edit (no-op when empty). public void Undo() { diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index b8af375..61e532e 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -277,10 +277,48 @@ public bool CanDeleteSelection() => /// FrameResource — both undoable and both persisted by Save/Build. public void DeleteSelected() { + // An object carried in from another archive takes the collision it was given along — those hulls + // stand where it stood and are nothing without it. + List selected = [.. Selection.Selected]; + List linked = [.. selected.SelectMany(LinkedCollisionNodes).Where(n => !selected.Contains(n)).Distinct()]; + if (linked.Count > 0) Selection.SetSelection([.. selected, .. linked], SelectedNode); + + int before = Editing.History.UndoCount; ActorEditing.DeleteSelected(); // actors (drops their record from the .act pack) CollisionEditing.DeleteSelected(); // collision instances (drops them from the selection) CrashEditing.DeleteSelected(); // city_crash placements, in both seasons when linked Editing.DeleteSelected(); // frame objects (its DeletableRoots excludes collision) + // One Delete is one Ctrl+Z, however many kinds of thing it took out. + Editing.History.SquashSince(before, edits => new CompositeEdit(edits)); + } + + /// + /// The collision placements a piece of scenery this toolkit carried in was given (): + /// for each recorded hull, the placement of it nearest the object, within a few metres. Empty for everything + /// else — a stock object's collision is not tied to it by anything, and guessing by position would take a + /// building's hull along with a bench. + /// + internal IReadOnlyList LinkedCollisionNodes(SceneNode node) + { + if (node.Source is not FrameNodeAdapter frame || node.OwningDocumentNode() is not { Source: SceneDocumentAdapter scene } documentNode) + { + return []; + } + IReadOnlyList hulls = Assets.Sds.ImportLinks.HullsOf(Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive), + frame.Frame.Name.String); + if (hulls.Count == 0 || FindCollisionLayer(documentNode) is not { } layer) return []; + + Vector3 at = frame.WorldTransform.Translation; + var found = new List(); + foreach (ulong hash in hulls) + { + SceneNode? nearest = layer.Children + .Where(c => c.Source is CollisionInstanceAdapter ci && ci.Instance.Hash == hash + && Vector3.Distance(ci.Instance.Position, at) < 5f) + .MinBy(c => Vector3.Distance(((CollisionInstanceAdapter)c.Source!).Instance.Position, at)); + if (nearest != null) found.Add(nearest); + } + return found; } /// Whether the selection has anything duplicable — a static mesh or a collision placement. diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index 9629545..e06f195 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -287,6 +287,9 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO string name = hulls.Count == 1 ? $"{carried.Root.Name} collision" : $"{carried.Root.Name} collision {i + 1}"; edits.AddRange(_host.CollisionEditing.BuildCreateHull(document, layer, hulls[i].Added, hulls[i].Placement, name) ?? []); } + // Written down, so the hulls move and are deleted with the object (see D3DImageHost.LinkedCollisionNodes). + Assets.Sds.ImportLinks.Set(Assets.MafiaEnvironment.ExtractedDir(destination), carried.Root.Name.String, + hulls.Select(h => h.Placement.Hash)); summary = hulls.Count == 0 ? "none — " + (refusal ?? "nothing to make one of") : hulls[0].FromSource diff --git a/src/Illusion/Viewport/TransformEditController.cs b/src/Illusion/Viewport/TransformEditController.cs index 17be0b0..33ab607 100644 --- a/src/Illusion/Viewport/TransformEditController.cs +++ b/src/Illusion/Viewport/TransformEditController.cs @@ -73,6 +73,19 @@ public void GizmoBeginDrag(GizmoMode mode) if (n.Source is not IFrameNode fn || HasSelectedAncestor(fn, frames)) continue; _dragGroup.Add((n, fn.WorldTransform, fn.LocalTransform)); } + + // An object carried in from another archive drags the collision it was given along with it: nothing in + // the file ties the two, so without this the hull would stay where the object used to stand. + foreach ((SceneNode node, _, _) in _dragGroup.ToList()) + { + foreach (SceneNode hull in _host.LinkedCollisionNodes(node)) + { + if (hull.Source is IFrameNode h && _dragGroup.All(g => !ReferenceEquals(g.Node, hull))) + { + _dragGroup.Add((hull, h.WorldTransform, h.LocalTransform)); + } + } + } } // True if any frame-graph ancestor of fn is itself selected (its cascade will move fn). From 745476e98aa1bcdd2afd3a7a9ac86d4be55179af Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:01:19 +0300 Subject: [PATCH 025/102] feat: a duplicate of an imported object gets copies of its collision Duplicating scenery the toolkit carried in also places copies of the hulls it was given, where the copy stands, links them to the copy under its own name, and the whole duplicate is one Ctrl+Z. Linked hulls are now claimed one to one across the scene, nearest first and ties to the name that sorts first: a copy stands on top of its original with the very same hull, and dragging it used to drag the original's hull away. --- src/Illusion.Assets/Sds/ImportLinks.cs | 12 ++- src/Illusion/Viewport/D3DImageHost.cs | 93 +++++++++++++++++-- .../Viewport/TransformEditController.cs | 8 ++ 3 files changed, 101 insertions(+), 12 deletions(-) diff --git a/src/Illusion.Assets/Sds/ImportLinks.cs b/src/Illusion.Assets/Sds/ImportLinks.cs index 0ab780c..3a0e146 100644 --- a/src/Illusion.Assets/Sds/ImportLinks.cs +++ b/src/Illusion.Assets/Sds/ImportLinks.cs @@ -9,8 +9,9 @@ namespace Illusion.Assets.Sds; /// Nothing in the archive ties a static object to its collision: the hull is a placement in the collision /// resource that happens to stand where the object stands. For the objects this toolkit carried in, the tie is /// written down here, beside the working copy and outside its manifest (the game never sees it): the object's -/// name and the hashes of the hulls it was given. The placements themselves are found again by hash, the one -/// nearest the object — their positions change whenever the object is moved, their hulls do not. +/// name and the hashes of the hulls it was given. The placements themselves are found again by hash, nearest +/// first — their positions change whenever the object is moved, their hulls do not — and each placement goes +/// to one object only: a copy stands on top of its original with the very same hull, and must not take it. /// /// public static class ImportLinks @@ -44,6 +45,13 @@ public static IReadOnlyList HullsOf(string extractedDir, string frameName return frameName is { Length: > 0 } && Read(extractedDir).TryGetValue(frameName, out List? hulls) ? hulls : []; } + /// Every object of the archive that has hulls recorded, with them. + public static IReadOnlyDictionary> All(string extractedDir) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + return Read(extractedDir); + } + private static Dictionary> Read(string extractedDir) { string path = Path.Combine(extractedDir, FileName); diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index 61e532e..5abf623 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -304,19 +304,44 @@ internal IReadOnlyList LinkedCollisionNodes(SceneNode node) { return []; } - IReadOnlyList hulls = Assets.Sds.ImportLinks.HullsOf(Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive), - frame.Frame.Name.String); - if (hulls.Count == 0 || FindCollisionLayer(documentNode) is not { } layer) return []; + string dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); + string name = frame.Frame.Name.String; + if (Assets.Sds.ImportLinks.HullsOf(dir, name).Count == 0 || FindCollisionLayer(documentNode) is not { } layer) return []; + + // Every linked object of the archive bids for the placements of its hulls, nearest first, and a placement + // goes to the first bid only. A tie — a copy on top of its original — goes to the name that sorts first, + // so the answer does not change from one call to the next. + var standing = new Dictionary(StringComparer.Ordinal); + foreach (object value in scene.Frame.FrameObjects.Values) + { + if (value is Formats.Frames.ObjectTypes.FrameObjectBase f && f.Name.String is { Length: > 0 } n) standing.TryAdd(n, f.WorldTransform.Translation); + } + var bids = new List<(float Distance, string Object, int Slot, int Placement)>(); + List placements = [.. layer.Children]; + foreach ((string linked, List hulls) in Assets.Sds.ImportLinks.All(dir)) + { + if (!standing.TryGetValue(linked, out Vector3 at)) continue; + for (int slot = 0; slot < hulls.Count; slot++) + { + for (int i = 0; i < placements.Count; i++) + { + if (placements[i].Source is not CollisionInstanceAdapter ci || ci.Instance.Hash != hulls[slot]) continue; + float distance = Vector3.Distance(ci.Instance.Position, at); + if (distance < 5f) bids.Add((distance, linked, slot, i)); + } + } + } - Vector3 at = frame.WorldTransform.Translation; + var takenPlacements = new HashSet(); + var filledSlots = new HashSet<(string, int)>(); var found = new List(); - foreach (ulong hash in hulls) + foreach ((_, string linked, int slot, int placement) in bids + .OrderBy(b => b.Distance).ThenBy(b => b.Object, StringComparer.Ordinal).ThenBy(b => b.Placement)) { - SceneNode? nearest = layer.Children - .Where(c => c.Source is CollisionInstanceAdapter ci && ci.Instance.Hash == hash - && Vector3.Distance(ci.Instance.Position, at) < 5f) - .MinBy(c => Vector3.Distance(((CollisionInstanceAdapter)c.Source!).Instance.Position, at)); - if (nearest != null) found.Add(nearest); + if (takenPlacements.Contains(placement) || filledSlots.Contains((linked, slot))) continue; + takenPlacements.Add(placement); + filledSlots.Add((linked, slot)); + if (linked == name) found.Add(placements[placement]); } return found; } @@ -330,10 +355,58 @@ public bool CanDuplicateSelection() => /// deep, independent copies in their FrameResource — both undoable and persisted. public void DuplicateSelected() { + List selected = [.. Selection.Selected]; + int before = Editing.History.UndoCount; CollisionEditing.DuplicateSelected(); // collision placements (re-selects the copies) CrashEditing.DuplicateSelected(); // city_crash placements (re-selects the copies) ActorEditing.DuplicateSelected(); // actors (a copy of the record, under a fresh name) Editing.DuplicateSelected(); // frame objects (skips collision sources) + DuplicateLinkedCollision(selected); + // One Duplicate is one Ctrl+Z, the copies' collision included. + Editing.History.SquashSince(before, edits => new CompositeEdit(edits)); + } + + // A copy of an object carried in from another archive gets copies of the collision it was given — the same + // hulls, placed where the copy stands (where the original stands: a duplicate starts on top of it) — and is + // linked to them under its own name, so it then moves and is deleted with them like the original. Hulls the + // selection already held were copied by the collision duplicate and are left alone here. + private void DuplicateLinkedCollision(IReadOnlyList selected) + { + foreach ((SceneNode source, SceneNode copy) in Editing.LastDuplicates) + { + if (copy.Source is not FrameNodeAdapter copyFrame + || copy.OwningDocumentNode() is not { Source: SceneDocumentAdapter scene } documentNode + || FindCollisionLayer(documentNode) is not { Source: CollisionDocumentAdapter collision } layer) + { + continue; + } + var hashes = new List(); + foreach (SceneNode hull in LinkedCollisionNodes(source)) + { + if (hull.Source is not CollisionInstanceAdapter original) continue; + hashes.Add(original.Instance.Hash); + if (selected.Contains(hull)) continue; + var placement = new Formats.Collisions.CollisionInstance + { + Position = original.Instance.Position, + Rotation = original.Instance.Rotation, + Hash = original.Instance.Hash, + Unk4 = -1, + Group = original.Instance.Group, + }; + foreach (Domain.IEditAction edit in CollisionEditing.BuildCreateHull(collision, layer, null, placement, + $"{copyFrame.Frame.Name} collision") ?? []) + { + edit.Redo(); + Editing.History.Push(edit); + } + } + if (hashes.Count > 0) + { + Assets.Sds.ImportLinks.Set(Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive), + copyFrame.Frame.Name.String, hashes); + } + } } /// Fills in the placements of a crash row when its tree branch opens. The copies are not diff --git a/src/Illusion/Viewport/TransformEditController.cs b/src/Illusion/Viewport/TransformEditController.cs index 33ab607..9cb9adb 100644 --- a/src/Illusion/Viewport/TransformEditController.cs +++ b/src/Illusion/Viewport/TransformEditController.cs @@ -287,7 +287,9 @@ public bool CanDuplicateSelection() => /// with a notice. public void DuplicateSelected() { + LastDuplicates = []; var items = new List(); + var pairs = new List<(SceneNode Source, SceneNode Copy)>(); int skipped = 0; string? lastReason = null; foreach (SceneNode n in _host.Selection.Selected.ToList()) @@ -313,17 +315,23 @@ public void DuplicateSelected() _host.Persistence.MarkFrameModified(leaf); if (dup.IsOnNameTable) _host.Persistence.MarkNameTableDirty(leaf); items.Add(new DuplicatedItem(leaf, n.Parent, dup, mesh)); + pairs.Add((n, leaf)); } if (skipped > 0) _host.RaiseNotice($"{skipped} object(s) not duplicated — {lastReason ?? "unsupported object"}"); if (items.Count == 0) return; + LastDuplicates = pairs; History.Push(new DuplicateEdit(this, items.ToArray())); // already applied above — pushed, not redone _host.Selection.SetSelection(items.Select(i => i.Node).ToList(), items[^1].Node); _host.RaiseSceneChanged(); } + /// What the last copied: each source row and the row of its copy — + /// for the host, which gives a copy of an imported object copies of the collision it was given. + public IReadOnlyList<(SceneNode Source, SceneNode Copy)> LastDuplicates { get; private set; } = []; + private sealed record DuplicatedItem( SceneNode Node, SceneNode Parent, FrameDuplicator.DuplicatedObject Duplicate, GpuMesh Mesh); From ea853116aa750af95f41a35d485685afb277f04c Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:16:39 +0300 Subject: [PATCH 026/102] feat: save drops geometry nothing draws; a re-import shares geometry - Save writes the buffer pools without the buffers no geometry block draws from - left behind by a deleted object, a sanitised block, a rebuilt mesh. Measured: none of the 130 archives as shipped carries one (--probe-geometry-sweep), the install's working copies had ~0.5 MB. Only the files lose them: memory keeps them, so an undo after a save brings the object back and the next save writes them again (SdsGeometrySaver.SavePools tracks what each pool file holds). - Importing an object again draws from the buffers - and, when every level is there, the geometry block - its earlier import brought, the way the shipped scenes put many copies of one chair on one mesh. Where geometry went is remembered per source archive beside the working copy (ImportGeometry, illusion_geometry.json); a remembered buffer the scene no longer has is simply copied again. - The bridge probes snapshotted only the pool they edited; a save now may rewrite others, so they snapshot and restore every pool. --probe-object-transplant: 38/0 (+ imported twice shares, the memory survives on disk, undoing copies keeps the first, a save leaves dead buffers out of the files and writes them back when drawn again). --- README.md | 4 +- .../Adapters/SceneDocumentAdapter.cs | 7 +- .../Bridge/SdsGeometrySaver.cs | 73 ++++++++++++++++ src/Illusion.Assets/Frames/FrameTransplant.cs | 70 +++++++++++++-- src/Illusion.Assets/Sds/GeometrySweep.cs | 58 +++++++++++++ src/Illusion.Assets/Sds/ImportGeometry.cs | 76 +++++++++++++++++ src/Illusion/Diagnostics/ProbeRunner.cs | 5 ++ .../Diagnostics/Probes/BridgeProbes.cs | 15 ++-- .../Diagnostics/Probes/GeometrySweepProbes.cs | 85 +++++++++++++++++++ .../Probes/ObjectTransplantProbes.cs | 54 ++++++++++++ .../Viewport/ObjectImportController.cs | 10 ++- 11 files changed, 440 insertions(+), 17 deletions(-) create mode 100644 src/Illusion.Assets/Sds/GeometrySweep.cs create mode 100644 src/Illusion.Assets/Sds/ImportGeometry.cs create mode 100644 src/Illusion/Diagnostics/Probes/GeometrySweepProbes.cs diff --git a/README.md b/README.md index 6213759..b47114e 100644 --- a/README.md +++ b/README.md @@ -270,8 +270,8 @@ inside is not decoded, and the responses say so rather than looking complete. - The Resource Editor tile is a stub. - Duplicating frame objects covers static single-mesh objects only. -- Objects carried from another archive: skinned models cannot travel yet, each import copies - its geometry — import once and duplicate inside the district rather than importing the same thing twice. +- Objects carried from another archive: skinned models cannot travel yet, importing the same object + again shares the geometry the first import brought, while Ctrl+D still makes a full copy. - A topology rebuild does not regenerate lower LODs or collision for that object. - `.sds.patch`, `.tra` and `cityareas.bin` are read-only. `StreamMap*.bin` is read-only in the editor; the MCP `edit_stream_map` tool can rewrite its strings in place (see above). diff --git a/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs b/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs index f407a71..8de4132 100644 --- a/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs +++ b/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs @@ -20,6 +20,7 @@ public sealed class SceneDocumentAdapter : ISceneDocument private readonly HashSet _dirtyVertexBuffers = new(); private readonly HashSet _dirtyIndexBuffers = new(); private bool _nameTableDirty; + private readonly Bridge.SdsGeometrySaver.PoolState _pools = new(); public SceneDocumentAdapter(FrameResource frame, FileInfo sourceArchive, ActorPlacements? placements = null) { @@ -217,9 +218,11 @@ public string SaveWorkingCopy() // The pools describe geometry the FRAME RESOURCE indexes, so they go only when it went. A refused car // save wrote neither, and flushing the buffers anyway would leave the working copy holding vertices // no frame in it accounts for. - if (MarkerRowsRefused == null && (_dirtyVertexBuffers.Count > 0 || _dirtyIndexBuffers.Count > 0)) + // Every pool is looked at, not only the dirty ones: a deleted object or a rebuilt mesh leaves buffers + // nothing draws from any more, and those stay out of the files (in memory they stay, for undo). + if (MarkerRowsRefused == null) { - Bridge.SdsGeometrySaver.SaveDirtyPools(_frame, _dirtyVertexBuffers, _dirtyIndexBuffers); + Bridge.SdsGeometrySaver.SavePools(_frame, _dirtyVertexBuffers, _dirtyIndexBuffers, _pools); _dirtyVertexBuffers.Clear(); // the working copy now matches memory _dirtyIndexBuffers.Clear(); } diff --git a/src/Illusion.Assets/Bridge/SdsGeometrySaver.cs b/src/Illusion.Assets/Bridge/SdsGeometrySaver.cs index 47b711d..3259998 100644 --- a/src/Illusion.Assets/Bridge/SdsGeometrySaver.cs +++ b/src/Illusion.Assets/Bridge/SdsGeometrySaver.cs @@ -60,6 +60,79 @@ public static int SaveDirtyPools(FrameResource frame, IReadOnlyCollection return written; } + /// What each pool file on disk holds, by path — what compares against to know + /// which files a change of membership touched. Owned by the document; filled as pools are first seen. + public sealed class PoolState + { + internal readonly Dictionary> Vertex = new(StringComparer.OrdinalIgnoreCase); + internal readonly Dictionary> Index = new(StringComparer.OrdinalIgnoreCase); + } + + /// + /// Writes the pools as the scene now needs them: every pool file holding a dirty buffer, and every pool file + /// whose set of LIVE buffers — the ones some geometry block draws from — is not what the file holds. A buffer + /// nothing draws from is left out of the file, never out of memory: an undone delete brings its object back + /// drawing from the very same buffers, and the next save writes them again. The shipped archives carry no + /// such buffers (--probe-geometry-sweep), so this keeps an edited archive their shape. + /// + /// The number of pool files written and the buffers left out of them. + public static (int Written, int LeftOut) SavePools(FrameResource frame, IReadOnlyCollection dirtyVertexBuffers, + IReadOnlyCollection dirtyIndexBuffers, PoolState state) + { + ArgumentNullException.ThrowIfNull(frame); + ArgumentNullException.ThrowIfNull(state); + (HashSet liveVertex, HashSet liveIndex) = Sds.GeometrySweep.Referenced(frame); + int written = 0, leftOut = 0; + + foreach (BufferPoolSource source in frame.VertexBuffers.Sources) + { + HashSet onDisk = Known(state.Vertex, source); + List keep = [.. source.Hashes.Where(liveVertex.Contains)]; + leftOut += source.Hashes.Count - keep.Count; + if (!keep.Any(dirtyVertexBuffers.Contains) && onDisk.SetEquals(keep)) continue; + if (keep.Count == 0 && source.IsNew) continue; // a pool that never held anything need not exist + WritePool(source, null, stream => + { + var pool = new VertexBufferPool(); + foreach (ulong hash in keep) pool.Buffers[hash] = frame.VertexBuffers.GetBuffer(hash)!; + pool.WriteToFile(stream); + }); + Announce(source, "VertexBufferPool", null); + state.Vertex[source.FilePath] = [.. keep]; + written++; + } + + foreach (BufferPoolSource source in frame.IndexBuffers.Sources) + { + HashSet onDisk = Known(state.Index, source); + List keep = [.. source.Hashes.Where(liveIndex.Contains)]; + leftOut += source.Hashes.Count - keep.Count; + if (!keep.Any(dirtyIndexBuffers.Contains) && onDisk.SetEquals(keep)) continue; + if (keep.Count == 0 && source.IsNew) continue; + WritePool(source, null, stream => + { + var pool = new IndexBufferPool(); + foreach (ulong hash in keep) pool.Buffers[hash] = frame.IndexBuffers.GetBuffer(hash)!; + pool.WriteToFile(stream); + }); + Announce(source, "IndexBufferPool", null); + state.Index[source.FilePath] = [.. keep]; + written++; + } + return (written, leftOut); + } + + // What the file holds, the first time a pool is asked about: everything it was loaded with — or nothing, + // for a pool this session invented and has not written yet. + private static HashSet Known(Dictionary> state, BufferPoolSource source) + { + if (!state.TryGetValue(source.FilePath, out HashSet? known)) + { + state[source.FilePath] = known = source.IsNew || !File.Exists(source.FilePath) ? [] : [.. source.Hashes]; + } + return known; + } + /// /// Puts a pool file the toolkit invented into the archive's SDSContent.xml. Packing goes by the manifest, /// not by the folder, so without this the new pool is silently dropped at Build and the archive ends up diff --git a/src/Illusion.Assets/Frames/FrameTransplant.cs b/src/Illusion.Assets/Frames/FrameTransplant.cs index d7a44a2..a919003 100644 --- a/src/Illusion.Assets/Frames/FrameTransplant.cs +++ b/src/Illusion.Assets/Frames/FrameTransplant.cs @@ -169,7 +169,19 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => /// The root's world matrix, for . Ignored for a /// prototype, which keeps the transform it has. public static TransplantedObject? TryTransplant(ISceneDocument document, FrameResource source, - FrameObjectBase root, string name, Standing standing, Matrix4x4 world, out string? skipReason) + FrameObjectBase root, string name, Standing standing, Matrix4x4 world, out string? skipReason) => + TryTransplant(document, source, root, name, standing, world, shared: null, out skipReason); + + /// + /// The same, drawing from geometry an earlier import of the same object already brought when + /// remembers it and the scene still has it — the buffers and, when every level of + /// detail is there, the geometry block itself, which is how the shipped scenes put many copies of one thing + /// on one mesh. What is copied afresh is remembered in for the next time; the caller + /// saves it. + /// + public static TransplantedObject? TryTransplant(ISceneDocument document, FrameResource source, + FrameObjectBase root, string name, Standing standing, Matrix4x4 world, ImportGeometry? shared, + out string? skipReason) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(root); @@ -209,7 +221,8 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => var result = new TransplantedObject { Resource = resource, Adapter = adapter, Anchor = anchor }; string unique = Guid.NewGuid().ToString("N")[..8]; - if (!CopyBuffers(source, resource, subtree, name, unique, result, + var reused = new HashSet(); // source buffers this copy draws from without copying them + if (!CopyBuffers(source, resource, subtree, name, unique, result, shared, reused, out Dictionary vertexNames, out Dictionary indexNames, out skipReason)) { foreach (VertexBuffer vb in result.VertexBuffers) resource.VertexBuffers.Remove(vb.Hash); @@ -243,7 +256,7 @@ public static bool CanTransplant(FrameObjectBase root, out string? reason) => if (copy is FrameObjectSingleMesh mesh && original is FrameObjectSingleMesh originalMesh) { - CopyBlocks(resource, originalMesh, mesh, geometries, materials, vertexNames, indexNames, result); + CopyBlocks(resource, originalMesh, mesh, geometries, materials, vertexNames, indexNames, reused, result); } resource.FrameObjects.Add(copy.RefID, copy); @@ -368,7 +381,7 @@ private static void Collect(FrameObjectBase frame, List into, H // Verbatim copies of every buffer the subtree's meshes draw from, under fresh names, registered before // anything else mutates — the pools must have room first. A buffer several meshes share is copied once. private static bool CopyBuffers(FrameResource source, FrameResource resource, List subtree, - string name, string unique, TransplantedObject into, + string name, string unique, TransplantedObject into, ImportGeometry? shared, HashSet reused, out Dictionary vertexNames, out Dictionary indexNames, out string? reason) { vertexNames = new Dictionary(); @@ -387,6 +400,14 @@ private static bool CopyBuffers(FrameResource source, FrameResource resource, Li reason = $"'{mesh.Name}' draws from a buffer its own archive does not carry"; return false; } + if (!vertexNames.ContainsKey(vertexHash) + && shared?.Copied('v', vertexHash) is { } earlierVertex + && resource.VertexBuffers.GetBuffer(new HashName(earlierVertex).Hash) is { } existingVertex + && existingVertex.Data.AsSpan().SequenceEqual(vb.Data)) + { + vertexNames[vertexHash] = new HashName(earlierVertex); + reused.Add(vertexHash); + } if (!vertexNames.ContainsKey(vertexHash)) { var bufferName = new HashName($"{name}_vb{vertexHash:x16}_{unique}"); @@ -398,6 +419,15 @@ private static bool CopyBuffers(FrameResource source, FrameResource resource, Li } vertexNames[vertexHash] = bufferName; into.VertexBuffers.Add(copy); + shared?.Remember('v', vertexHash, bufferName.String); + } + if (!indexNames.ContainsKey(indexHash) + && shared?.Copied('i', indexHash) is { } earlierIndex + && resource.IndexBuffers.GetBuffer(new HashName(earlierIndex).Hash) is { } existingIndex + && existingIndex.GetData().AsSpan().SequenceEqual(ib.GetData())) + { + indexNames[indexHash] = new HashName(earlierIndex); + reused.Add(indexHash); } if (!indexNames.ContainsKey(indexHash)) { @@ -412,6 +442,7 @@ private static bool CopyBuffers(FrameResource source, FrameResource resource, Li } indexNames[indexHash] = bufferName; into.IndexBuffers.Add(copy); + shared?.Remember('i', indexHash, bufferName.String); } } } @@ -422,10 +453,19 @@ private static bool CopyBuffers(FrameResource source, FrameResource resource, Li // repointed at the copied buffers. private static void CopyBlocks(FrameResource resource, FrameObjectSingleMesh original, FrameObjectSingleMesh copy, Dictionary geometries, Dictionary materials, - Dictionary vertexNames, Dictionary indexNames, TransplantedObject into) + Dictionary vertexNames, Dictionary indexNames, HashSet reused, + TransplantedObject into) { if (original.Refs.ContainsKey(FrameEntryRefTypes.Geometry)) { + // Every level already here: the block that draws them is here too — share it, as the shipped scenes + // share one chair's block between all their chairs. It is not this copy's to take out on undo. + if (!geometries.ContainsKey(original.Geometry) + && original.Geometry.LOD.All(l => reused.Contains(l.VertexBufferRef.Hash) && reused.Contains(l.IndexBufferRef.Hash)) + && resource.FrameGeometries.Values.FirstOrDefault(g => Draws(g, original.Geometry, vertexNames, indexNames)) is { } existing) + { + geometries[original.Geometry] = existing; + } if (!geometries.TryGetValue(original.Geometry, out FrameGeometry? geometry)) { geometry = resource.ConstructFrameAssetOfType(); @@ -458,6 +498,26 @@ private static void CopyBlocks(FrameResource resource, FrameObjectSingleMesh ori } } + // Whether a block of this scene draws exactly what the source block does, from the buffers it was copied to. + private static bool Draws(FrameGeometry candidate, FrameGeometry original, Dictionary vertexNames, + Dictionary indexNames) + { + if (candidate.LOD is not { } lods || lods.Length != original.LOD.Length) return false; + if (candidate.DecompressionOffset != original.DecompressionOffset || candidate.DecompressionFactor != original.DecompressionFactor) + { + return false; + } + for (int i = 0; i < lods.Length; i++) + { + if (lods[i].VertexBufferRef.Hash != vertexNames[original.LOD[i].VertexBufferRef.Hash].Hash + || lods[i].IndexBufferRef.Hash != indexNames[original.LOD[i].IndexBufferRef.Hash].Hash) + { + return false; + } + } + return true; + } + // Kept in step with ActorPrototypeCloner.CanClone, which is what decided the subtree could be copied. private static FrameObjectBase? CopyOf(FrameObjectBase source) => source switch { diff --git a/src/Illusion.Assets/Sds/GeometrySweep.cs b/src/Illusion.Assets/Sds/GeometrySweep.cs new file mode 100644 index 0000000..9f6f961 --- /dev/null +++ b/src/Illusion.Assets/Sds/GeometrySweep.cs @@ -0,0 +1,58 @@ +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Geometry; + +namespace Illusion.Assets.Sds; + +/// +/// Which vertex and index buffers of a scene's pools nothing draws from any more. +/// +/// A buffer is named by the levels of detail of a geometry block, and by nothing else: a deleted object, a +/// geometry block the save sanitised away, or a Blender rebuild that gave a mesh new buffers all leave the +/// old ones in the pools, where they are packed and shipped as dead weight. The shipped archives carry none +/// (see --probe-geometry-sweep), so leaving them out of the files the toolkit writes keeps an edited +/// archive the shape the game's own are. +/// +/// +public static class GeometrySweep +{ + /// Every buffer some geometry block of the scene draws from. + public static (HashSet Vertex, HashSet Index) Referenced(FrameResource frame) + { + ArgumentNullException.ThrowIfNull(frame); + var vertex = new HashSet(); + var index = new HashSet(); + foreach (FrameGeometry geometry in frame.FrameGeometries.Values) + { + foreach (FrameLOD lod in geometry.LOD ?? []) + { + vertex.Add(lod.VertexBufferRef.Hash); + index.Add(lod.IndexBufferRef.Hash); + } + } + return (vertex, index); + } + + /// The buffers the pools hold that nothing draws from, and their size in bytes. + public static (List Vertex, List Index, long Bytes) Unreferenced(FrameResource frame) + { + ArgumentNullException.ThrowIfNull(frame); + (HashSet usedVertex, HashSet usedIndex) = Referenced(frame); + var vertex = new List(); + var index = new List(); + long bytes = 0; + foreach ((ulong hash, VertexBuffer buffer) in frame.VertexBuffers.Buffers) + { + if (usedVertex.Contains(hash)) continue; + vertex.Add(hash); + bytes += buffer.Data.Length; + } + foreach ((ulong hash, IndexBuffer buffer) in frame.IndexBuffers.Buffers) + { + if (usedIndex.Contains(hash)) continue; + index.Add(hash); + bytes += buffer.GetLength(); + } + return (vertex, index, bytes); + } +} diff --git a/src/Illusion.Assets/Sds/ImportGeometry.cs b/src/Illusion.Assets/Sds/ImportGeometry.cs new file mode 100644 index 0000000..79fab62 --- /dev/null +++ b/src/Illusion.Assets/Sds/ImportGeometry.cs @@ -0,0 +1,76 @@ +using System.Text.Json; + +namespace Illusion.Assets.Sds; + +/// +/// Where the geometry already carried into an archive went — so the same object imported again draws from the +/// same buffers instead of bringing another copy of them. +/// +/// The shipped scenes share geometry freely (ten wanted posters on one block, a hundred chairs on one), and an +/// archive that held ten copies of one chair's vertices would be ten times the size for nothing. Per source +/// archive, each source buffer is mapped to the name of the buffer it was copied into here. The map is a +/// memory, not a promise: a mapped buffer that is no longer in the scene (its import was undone, its object +/// deleted and saved away) is simply copied again. +/// +/// +public sealed class ImportGeometry +{ + private const string FileName = "illusion_geometry.json"; + + private readonly string _dir; + private readonly string _source; + private readonly Dictionary> _all; + + private ImportGeometry(string dir, string source, Dictionary> all) + { + _dir = dir; + _source = source; + _all = all; + } + + /// The map for objects from carried into . + public static ImportGeometry Load(string extractedDir, string sourceArchive) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + ArgumentException.ThrowIfNullOrEmpty(sourceArchive); + string path = Path.Combine(extractedDir, FileName); + Dictionary>? all = null; + try + { + if (File.Exists(path)) all = JsonSerializer.Deserialize>>(File.ReadAllText(path)); + } + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + { + all = null; + } + return new ImportGeometry(extractedDir, sourceArchive.ToLowerInvariant(), all ?? new()); + } + + /// The name of the buffer a source buffer was copied into before, or null. + public string? Copied(char kind, ulong sourceHash) => + _all.TryGetValue(_source, out Dictionary? map) && map.TryGetValue(Key(kind, sourceHash), out string? name) + ? name + : null; + + /// Remembers where a source buffer was copied to. + public void Remember(char kind, ulong sourceHash, string destinationName) + { + if (!_all.TryGetValue(_source, out Dictionary? map)) _all[_source] = map = new(); + map[Key(kind, sourceHash)] = destinationName; + } + + /// Writes the map back beside the working copy (outside its manifest — the game never sees it). + public void Save() + { + try + { + AtomicFile.WriteAllBytes(Path.Combine(_dir, FileName), JsonSerializer.SerializeToUtf8Bytes(_all)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // forgetting costs the next import of this object a copy of its geometry, nothing more + } + } + + private static string Key(char kind, ulong hash) => $"{kind}{hash:x16}"; +} diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 2ada6cf..6940ad2 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -173,6 +173,11 @@ public static bool TryRun(string[] args) case "--probe-prop-catalog": PropCatalogProbes.RunPropCatalogProbe(); return true; + // Buffers nothing draws from: none in the archives as shipped, how many in the working copies. + // Output: %TEMP%\illusion_geometry_sweep.txt + case "--probe-geometry-sweep": + GeometrySweepProbes.RunGeometrySweepProbe(); + return true; case "--probe-object-transplant": ObjectTransplantProbes.RunObjectTransplantProbe( args.Length >= 2 ? args[1] : "hill", diff --git a/src/Illusion/Diagnostics/Probes/BridgeProbes.cs b/src/Illusion/Diagnostics/Probes/BridgeProbes.cs index 671a0b5..451dff0 100644 --- a/src/Illusion/Diagnostics/Probes/BridgeProbes.cs +++ b/src/Illusion/Diagnostics/Probes/BridgeProbes.cs @@ -1066,9 +1066,13 @@ void Find(SdsFrameNode n) var frame = ((FrameNodeAdapter)fn).Frame as Formats.Frames.ObjectTypes.FrameObjectSingleMesh; ulong bufferHash = frame!.Geometry.LOD[0].VertexBufferRef.Hash; - // Pool paths come from the earlier OpenScene load — same files, same hashes. + // Pool paths come from the earlier OpenScene load — same files, same hashes. EVERY pool, not only the + // one carrying the edited buffer: a save also rewrites any pool holding a buffer nothing draws from + // (SdsGeometrySaver.SavePools), and a working copy edited by hand may have one. foreach (var src in fr.VertexBuffers.Sources) - if (src.Hashes.Contains(bufferHash)) restore[src.FilePath] = File.ReadAllBytes(src.FilePath); + if (File.Exists(src.FilePath)) restore[src.FilePath] = File.ReadAllBytes(src.FilePath); + foreach (var src in fr.IndexBuffers.Sources) + if (File.Exists(src.FilePath)) restore[src.FilePath] = File.ReadAllBytes(src.FilePath); Vector3 delta = new(payload.DecompressionFactor * 8f, 0f, 0f); Vector3 movedTarget = payload.Positions[0] + delta; @@ -1409,10 +1413,9 @@ void AddTri(uint x, uint y, uint z, Vector3 n0, Vector3 n1, Vector3 n2, var scene = SdsMeshLoader.OpenScene(extracted); ulong vbHash = frame.Geometry.LOD[0].VertexBufferRef.Hash; ulong ibHash = frame.Geometry.LOD[0].IndexBufferRef.Hash; - foreach (var src in scene.VertexBuffers.Sources) - if (src.Hashes.Contains(vbHash)) restore.TryAdd(src.FilePath, File.ReadAllBytes(src.FilePath)); - foreach (var src in scene.IndexBuffers.Sources) - if (src.Hashes.Contains(ibHash)) restore.TryAdd(src.FilePath, File.ReadAllBytes(src.FilePath)); + // Every pool: the save also rewrites any pool holding a buffer nothing draws from. + foreach (var src in scene.VertexBuffers.Sources.Concat(scene.IndexBuffers.Sources)) + if (File.Exists(src.FilePath)) restore.TryAdd(src.FilePath, File.ReadAllBytes(src.FilePath)); subApplied.ApplyNew(); document.SaveWorkingCopy(); diff --git a/src/Illusion/Diagnostics/Probes/GeometrySweepProbes.cs b/src/Illusion/Diagnostics/Probes/GeometrySweepProbes.cs new file mode 100644 index 0000000..3eab9ac --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/GeometrySweepProbes.cs @@ -0,0 +1,85 @@ +using System.IO; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Archive; +using Illusion.Formats.Frames; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Buffers nothing draws from: how many the archives as SHIPPED carry (read off each archive's oldest backup or +/// the archive itself, never a working copy someone has edited), and how many the install's working copies have +/// collected. The sweep at save rests on the first number being zero. +/// +internal static class GeometrySweepProbes +{ + // Output: %TEMP%\illusion_geometry_sweep.txt + internal static void RunGeometrySweepProbe() + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_geometry_sweep.txt"); + string scratch = Path.Combine(Path.GetTempPath(), "illusion_geometry_sweep"); + 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)}"); + } + + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + int shipped = 0, shippedWithDead = 0, working = 0; + long workingBytes = 0; + var notes = new List(); + foreach (string folder in new[] { "city", "shops" }) + { + foreach (string file in Directory.GetFiles(Path.Combine(MafiaEnvironment.PcFolder, "sds", folder), "*.sds")) + { + var sds = new FileInfo(file); + // As shipped: the oldest backup, or the archive when it was never built — extracted to scratch. + IReadOnlyList backups = SdsWriter.ListBackups(sds); + FileInfo pristine = backups.Count == 0 ? sds : backups[^1].File; + if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); + SdsArchive.Open(pristine.FullName).Extract(scratch); + ExtractedSds stock = ExtractedSds.Load(scratch); + if (stock.FrameResource is not { } scene) continue; + shipped++; + (List v, List i, long bytes) = GeometrySweep.Unreferenced(scene); + if (v.Count + i.Count > 0) + { + shippedWithDead++; + notes.Add($" shipped {Path.GetFileName(file)}: {v.Count} vertex + {i.Count} index buffers, {bytes:N0} bytes"); + } + + string dir = MafiaEnvironment.ExtractedDir(sds); + if (!File.Exists(Path.Combine(dir, "SDSContent.xml"))) continue; + ExtractedSds copy = ExtractedSds.Load(dir); + if (copy.FrameResource is not { } edited) continue; + (List wv, List wi, long wbytes) = GeometrySweep.Unreferenced(edited); + if (wv.Count + wi.Count == 0) continue; + working++; + workingBytes += wbytes; + notes.Add($" working copy {Path.GetFileName(file)}: {wv.Count} vertex + {wi.Count} index buffers, {wbytes:N0} bytes"); + } + } + foreach (string note in notes) sb.AppendLine(note); + Check("no archive as shipped carries a buffer nothing draws from", shipped > 0 && shippedWithDead == 0, + $"{shippedWithDead} of {shipped} do"); + sb.AppendLine($" working copies with dead buffers: {working}, {workingBytes:N0} bytes in all"); + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + try { if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); } + catch (IOException) { /* scratch left behind */ } + sb.Insert(0, $"GEOMETRY SWEEP PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } +} diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 1b99ffe..7978e83 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -269,6 +269,60 @@ [.. all.SelectMany(t => t.VertexBuffers).Select(b => b.Hash)], && PrefabFile.Load(barePrefabs[0]).PrefabCount == 1 && carriedDoor.CollisionHashes.All(ArchiveCarry.ItemDescriptionsOf(bareAfter).ContainsKey)); + // ── The same object imported again draws from the geometry the first import brought ── + ImportGeometry shared = ImportGeometry.Load(dir, sourceArchive); + TransplantedObject? one = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_share_1", + FrameTransplant.Standing.Prototype, Matrix4x4.Identity, shared, out reason); + TransplantedObject? two = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_share_2", + FrameTransplant.Standing.Prototype, Matrix4x4.Identity, shared, out reason); + shared.Save(); + static List Drawn(TransplantedObject t) => + [.. t.Pairs.Values.OfType().Where(m => m.Refs.ContainsKey(FrameEntryRefTypes.Geometry))]; + Check("imported twice, the second copy brings no buffers and draws from the first's geometry blocks", + one != null && two != null && one.VertexBuffers.Count > 0 && two.VertexBuffers.Count == 0 + && two.IndexBuffers.Count == 0 && two.Geometries.Count == 0 + && Drawn(two).All(m => Drawn(one).Any(f => ReferenceEquals(f.Geometry, m.Geometry))), + one == null || two == null ? reason ?? "" : $"{one.VertexBuffers.Count} buffers, then {two.VertexBuffers.Count}"); + if (one == null || two == null) return; + TransplantedObject? three = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_share_3", + FrameTransplant.Standing.Prototype, Matrix4x4.Identity, ImportGeometry.Load(dir, sourceArchive), out reason); + Check("what was shared is remembered on disk, for the next session", three is { VertexBuffers.Count: 0 }); + three?.Detach(); + two.Detach(); + Check("taking the copies out leaves the first one's geometry in place", + one.VertexBuffers.All(b => ours.VertexBuffers.GetBuffer(b.Hash) != null) + && one.Geometries.All(g => ours.FrameGeometries.ContainsKey(g.RefID))); + one.Detach(); + TransplantedObject? four = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_share_4", + FrameTransplant.Standing.Prototype, Matrix4x4.Identity, shared, out reason); + Check("with the first one gone too, the next import copies the geometry again", + four is { } f4 && f4.VertexBuffers.Count == one.VertexBuffers.Count); + four?.Detach(); + + // ── Save leaves out of the pools what nothing draws from, and puts it back when something does ── + var poolState = new SdsGeometrySaver.PoolState(); + TransplantedObject? swept = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_sweep", + FrameTransplant.Standing.Prototype, Matrix4x4.Identity, out reason); + if (swept == null) { Check("a copy to sweep", false, reason ?? ""); return; } + ulong[] sweptVertex = [.. swept.VertexBuffers.Select(b => b.Hash)]; + SdsGeometrySaver.SavePools(ours, sweptVertex, [.. swept.IndexBuffers.Select(b => b.Hash)], poolState); + bool OnDisk(ulong hash) + { + var pools = ExtractedSds.Load(dir).VertexBuffers; + return pools.GetBuffer(hash) != null; + } + bool savedWith = sweptVertex.All(OnDisk); + foreach (Formats.Frames.Resources.FrameGeometry g in swept.Geometries) ours.FrameGeometries.Remove(g.RefID); // drawn by nothing now + (int rewritten, int leftOut) = SdsGeometrySaver.SavePools(ours, [], [], poolState); + bool savedWithout = sweptVertex.All(h => !OnDisk(h)) && sweptVertex.All(h => ours.VertexBuffers.GetBuffer(h) != null); + foreach (Formats.Frames.Resources.FrameGeometry g in swept.Geometries) ours.FrameGeometries.TryAdd(g.RefID, g); + SdsGeometrySaver.SavePools(ours, [], [], poolState); + bool savedAgain = sweptVertex.All(OnDisk); + Check("a save leaves buffers nothing draws from out of the files but in memory, and writes them back once drawn again", + savedWith && savedWithout && savedAgain && leftOut >= sweptVertex.Length, + $"{rewritten} pool file(s) rewritten, {leftOut} buffer(s) left out"); + swept.Detach(); + // ── Simplified hulls: what a prop with no collision of its own is given ── var cube = new List(); for (int i = 0; i < 8; i++) cube.Add(new Vector3(i & 1, (i >> 1) & 1, (i >> 2) & 1)); diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index e06f195..14f7b09 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -136,6 +136,11 @@ public SceneNode ImportScenery(SceneNode frameRow, TransplantedObject carried, I // copy as soon as the frames are copied and BEFORE the viewport builds the meshes, which look their // textures up as they are made. Inert until something names them, so a refusal after this point // leaves a few unused files rather than a scene that names missing ones. + // Geometry this object's earlier imports already brought is drawn from rather than copied again. + Assets.Sds.ImportGeometry shared = Assets.Sds.ImportGeometry.Load( + Assets.MafiaEnvironment.ExtractedDir(destination), + Path.GetRelativePath(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds"), sds.FullName)); + Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedObject copied) => Assets.Sds.ArchiveCarry.Carry(sourceDir, Assets.MafiaEnvironment.ExtractedDir(destination), copied.MaterialHashes, copied.CollisionHashes, actor?.LinkedDefinition); @@ -149,7 +154,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO if (theirPlacements.PackOf(actor) is not { } theirPack) return $"'{name}' belongs to no pack"; carried = Assets.Frames.FrameTransplant.TryTransplant(scene, theirs, prototype, newName, - Assets.Frames.FrameTransplant.Standing.Prototype, Matrix4x4.Identity, out reason); + Assets.Frames.FrameTransplant.Standing.Prototype, Matrix4x4.Identity, shared, out reason); if (carried == null) return reason ?? "the object could not be copied"; carry = Carry(carried); if (ImportPlaced(actorsRow, frameRow, theirPack, actor, newName, at, facing, @@ -189,7 +194,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO world.Translation = at - Vector3.TransformNormal(standsOn, world); carried = Assets.Frames.FrameTransplant.TryTransplant(scene, theirs, frame, newName, - Assets.Frames.FrameTransplant.Standing.Scenery, world, out reason); + Assets.Frames.FrameTransplant.Standing.Scenery, world, shared, out reason); if (carried == null) return reason ?? "the object could not be copied"; carry = Carry(carried); ImportScenery(frameRow, carried, @@ -197,6 +202,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO kind = "scenery"; } + shared.Save(); _host.MarkArchiveModified(destination); outcome = new ObjectImportOutcome(kind, newName, carried.Pairs.Count, carried.Renderables.Count, From fac7b32e866461d3e662e0dcefc7f35da6dbe9be Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:29:17 +0300 Subject: [PATCH 027/102] feat(mcp): drive the resource editor; read and set car tuning - editor_target switches the scene tools between the map editor and the resource editor's stage: find, select, properties, move, duplicate, delete, camera, screenshot, shading, save, build, undo/redo and the Blender tools follow it, and answer with that editor's status. - resource_list searches the archive library (pc\sds by name or folder), resource_open stages one - from the launcher the way its tile does, beside the map editor as a second window - and waits for it to load; resource_status reports what is on the stage. - car_tuning lists a car's entity-data tables (stock and tuned variants, labelled by mass and power) and their fields by band and element; car_tuning_set writes one, parsed by its kind, with the Tuning tab's bookkeeping: an undo entry, the archive on the build list, the tab re-read. A wheel's or gear's field can be named without its prefix together with its element. Tried on shubert_38: 6 tables x 771 fields; Power, CenterOfMass and Wheel0.Scale set, read back and undone. --probe-mcp: 66/0. --- README.md | 4 + src/Illusion.Mcp/EditorModels.cs | 27 ++ src/Illusion.Mcp/IEditorSession.cs | 24 ++ src/Illusion.Mcp/Tools/EditorTools.cs | 145 +++++++- src/Illusion/Diagnostics/Probes/McpProbes.cs | 6 + src/Illusion/Mcp/AppEditorSession.cs | 316 ++++++++++++++++-- src/Illusion/ViewModels/SelectionViewModel.cs | 3 + src/Illusion/Views/LauncherWindow.xaml.cs | 10 + .../Views/ResourceEditorWindow.xaml.cs | 3 + 9 files changed, 495 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index b47114e..0202840 100644 --- a/README.md +++ b/README.md @@ -249,6 +249,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` | | **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` | +| **Resource editor** | `editor_target`, `resource_list`, `resource_open`, `resource_status`, `car_tuning`, `car_tuning_set` | | **Blender session** | `blender_open`, `blender_push`, `blender_end` | | **Viewport** | `camera_get`, `camera_set`, `camera_look_at`, `camera_frame_selection`, `view_set`, `viewport_screenshot` | @@ -257,6 +258,9 @@ commands do: every edit lands in the same undo history, nothing reaches disk bef and nothing reaches the game before `editor_build` (which keeps the usual timestamped backup). `scene_find` answers in world space and tests a box against the mesh's triangles, not its bounds; `viewport_screenshot` is how a client checks what a push actually looks like. +`editor_target resource` points the same tools at the resource editor's stage (one archive, such as +a car, opened with `resource_open`); `car_tuning` and `car_tuning_set` read and edit that car's +entity-data tables the way the Tuning tab does. Two of the file tools are worth knowing about before you rely on them. `edit_stream_map` is the only one that writes: it previews by default (`dryRun` is true unless you say otherwise), keeps a diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index 899b63f..b04439e 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -13,6 +13,33 @@ public sealed record EditorStatus( int BlenderObjects, string RenderMode); +/// What the resource editor is doing: whether it is open, which editor the scene tools drive +/// (map or resource), and the archive on its stage. +public sealed record ResourceStatus( + bool Open, + string Target, + string? Archive, + string? ArchivePath, + bool Loading, + int Meshes, + IReadOnlyList Selection, + bool UnsavedEdits, + IReadOnlyList PendingBuild, + int BlenderObjects, + string RenderMode); + +/// One archive of the game's library: its name, its path under pc\sds, what kind of thing it +/// holds (Car, Character, CityCrash, …), its size, and whether it already has a working copy. +public sealed record LibraryItem(string Name, string Path, string Kind, long Size, bool Extracted); + +/// One entity-data table of a car — a car ships several (the stock one and its tuned variants); the +/// label names its mass and power, which is what tells them apart. +public sealed record TuningTableInfo(int Table, string Label, string Type, int Fields); + +/// One named value of a car's tuning table: where it sits (band, element — a wheel, a gear), what it is +/// called, its kind (Number, Integer, Flag, Vector, Text) and its value as text. +public sealed record TuningFieldInfo(int Table, string Band, string? Element, string Label, string Name, string Kind, string Value); + /// One row of the scene tree. runs from the root, which is what tells two /// objects of the same name apart. Bounds are there only for a node that draws a mesh. The three /// "in box" members are filled when the search was given a box: how many of the mesh's triangles reach diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index 1679c00..af5098c 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -110,6 +110,30 @@ public interface IEditorSession /// Deletes the selection (undoable). Null on success. string? DeleteSelected(out int deleted); + /// Points the scene tools at the map editor (map) or the resource editor (resource). + /// Null on success; a note when the chosen editor is not open yet. + string? SetTarget(string target); + + /// The resource editor's state, and which editor the scene tools drive. + ResourceStatus ResourceStatus(); + + /// Opens the resource editor (if it is not open) on an archive — a path, a path under pc\sds + /// such as cars/shubert_38.sds, or a bare name — and makes it the target. Null when the load started. + string? OpenResource(string archive); + + /// Archives of the game's library, by name fragment and/or folder fragment. + IReadOnlyList Library(string? query, string? folder, int limit); + + /// The tuning tables of the archive on the resource editor's stage, and the fields of one of them + /// (1-based) whose name or label contains . + string? Tuning(int table, string? query, int limit, out IReadOnlyList tables, + out IReadOnlyList fields); + + /// Sets one tuning field (undoable; written to the working copy at once, packed by Build). The field + /// is its name; and narrow it when the name repeats (every + /// wheel has the same fields). Null on success. + string? SetTuning(int table, string field, string? band, string? element, string value, out TuningFieldInfo? result); + string? Undo(); string? Redo(); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index e599d72..c611fbf 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -31,6 +31,137 @@ public static async Task Status(IEditorSession editor, IUiThreadMarshal } } + [McpServerTool(Name = "editor_target")] + [Description("Choose which editor the scene tools drive: 'map' (the map editor, the default) or 'resource' (the resource editor — one archive such as a car on a stage). scene_find, scene_select, object_properties, object_set_property, object_move, scene_duplicate_selected, scene_delete_selected, camera_*, viewport_screenshot, view_set (shading only), editor_save, editor_build, editor_undo/redo and the blender_* tools all follow it. resource_open switches to 'resource' by itself.")] + public static async Task SetTarget( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("'map' or 'resource'.")] string target) + { + try + { + string? note = await ui.RunAsync(() => editor.SetTarget(target)); + return ToolResult.Json(new { success = true, note, resource = await ui.RunAsync(editor.ResourceStatus) }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "resource_list")] + [Description("List archives of the game's library — cars, characters, city objects, interiors — by name fragment and/or folder fragment ('cars', 'hchar', 'shops'). Each: name, path under pc\\sds, kind, size, and whether it already has a working copy.")] + public static async Task ResourceList( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("Part of the archive name, e.g. 'shubert'. Omit for all.")] string? query = null, + [Description("Part of the folder path, e.g. 'cars'. Omit for all.")] string? folder = null, + [Description("At most this many. Default 100.")] int limit = 100) + { + try + { + IReadOnlyList items = await ui.RunAsync(() => editor.Library(query, folder, limit)); + return ToolResult.Json(new { success = true, count = items.Count, archives = items }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "resource_open")] + [Description("Open an archive in the resource editor (opening the window if needed) and make it the target of the scene tools. Waits until it is on the stage. A car opens with its component tree (doors, bumpers, wheels) and its tuning.")] + public static async Task ResourceOpen( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The archive: a path under pc\\sds such as 'cars/shubert_38.sds', a full path, or a bare name such as 'shubert_38'.")] string archive, + [Description("How long to wait for the load, in seconds. Default 120.")] int timeoutSeconds = 120) + { + try + { + if (await ui.RunAsync(() => editor.OpenResource(archive)) is { } refused) return ToolResult.Invalid(refused); + DateTime until = DateTime.UtcNow.AddSeconds(Math.Clamp(timeoutSeconds, 1, 600)); + ResourceStatus status; + do + { + await Task.Delay(400); + status = await ui.RunAsync(editor.ResourceStatus); + } + while ((status.Loading || status.Meshes == 0) && DateTime.UtcNow < until); + return ToolResult.Json(new { success = true, loaded = !status.Loading && status.Meshes > 0, resource = status }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "resource_status")] + [Description("The resource editor's state: open or not, which editor the scene tools drive, the archive on its stage, loading, meshes, selection, unsaved edits, pending builds.")] + public static async Task ResourceStatusTool(IEditorSession editor, IUiThreadMarshal ui) + { + try + { + return ToolResult.Json(new { success = true, resource = await ui.RunAsync(editor.ResourceStatus) }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + // The status of the editor editor_target points at: the resource editor's, or the map editor's. + private static async Task StatusOf(IEditorSession editor, IUiThreadMarshal ui) + { + ResourceStatus resource = await ui.RunAsync(editor.ResourceStatus); + return resource.Target == "resource" ? resource : await ui.RunAsync(editor.Status); + } + + [McpServerTool(Name = "car_tuning")] + [Description("A car's tuning (entity data) in the resource editor: its tables — a car ships a stock one and tuned variants, labelled by mass and power — and the fields of one table: band (Body, Engine, Gearbox, Wheels…), element (a wheel, a gear), label, name, kind and value. Filter with a name fragment.")] + public static async Task CarTuning( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("Which table, 1-based. Default 1.")] int table = 1, + [Description("Part of a field's name or label, e.g. 'mass', 'gear', 'torque'. Omit for all.")] string? query = null, + [Description("At most this many fields. Default 200.")] int limit = 200) + { + try + { + IReadOnlyList tables = []; + IReadOnlyList fields = []; + string? refused = await ui.RunAsync(() => editor.Tuning(table, query, limit, out tables, out fields)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, tables, count = fields.Count, fields }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "car_tuning_set")] + [Description("Set one field of a car's tuning table (undoable with editor_undo; written to the working copy at once, packed into the archive by editor_build). Values: a number, true/false for a flag, 'x, y, z' for a vector, text for a name. When the name repeats — every wheel and gear has the same fields — give band and/or element as car_tuning lists them.")] + public static async Task CarTuningSet( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The field's name, as car_tuning lists it (e.g. 'Mass', 'Wheel0.Scale'); a wheel's or gear's field may be given without its prefix ('Scale') together with element.")] string field, + [Description("The new value, as text.")] string value, + [Description("Which table, 1-based. Default 1.")] int table = 1, + [Description("The band, when the name repeats across bands.")] string? band = null, + [Description("The element (a wheel, a gear), when the name repeats inside the band.")] string? element = null) + { + try + { + TuningFieldInfo? result = null; + string? refused = await ui.RunAsync(() => editor.SetTuning(table, field, band, element, value, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, field = result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + [McpServerTool(Name = "editor_list_areas")] [Description("Names of the areas (districts and interiors) the map editor can load. Empty until the editor is open — editor_open_area opens it.")] public static async Task ListAreas(IEditorSession editor, IUiThreadMarshal ui) @@ -129,7 +260,7 @@ public static async Task Select( try { if (await ui.RunAsync(() => editor.Select(names)) is { } refused) return ToolResult.Invalid(refused); - return ToolResult.Json(new { success = true, status = await ui.RunAsync(editor.Status) }); + return ToolResult.Json(new { success = true, status = await StatusOf(editor, ui) }); } catch (Exception ex) { @@ -211,7 +342,7 @@ public static async Task BlenderEnd(IEditorSession editor, IUiThreadMars try { if (await ui.RunAsync(editor.EndBlenderSession) is { } refused) return ToolResult.Invalid(refused); - return ToolResult.Json(new { success = true, status = await ui.RunAsync(editor.Status) }); + return ToolResult.Json(new { success = true, status = await StatusOf(editor, ui) }); } catch (Exception ex) { @@ -247,7 +378,7 @@ public static async Task Save(IEditorSession editor, IUiThreadMarshal ui string? failed = await ui.RunAsync(() => editor.Save(out files)); return failed != null ? ToolResult.Invalid(failed) - : ToolResult.Json(new { success = true, filesWritten = files, status = await ui.RunAsync(editor.Status) }); + : ToolResult.Json(new { success = true, filesWritten = files, status = await StatusOf(editor, ui) }); } catch (Exception ex) { @@ -393,7 +524,7 @@ public static async Task SetView( { if (await ui.RunAsync(() => editor.SetView(renderMode, collision, crash, zones, navigation)) is { } refused) return ToolResult.Invalid(refused); - return ToolResult.Json(new { success = true, status = await ui.RunAsync(editor.Status) }); + return ToolResult.Json(new { success = true, status = await StatusOf(editor, ui) }); } catch (Exception ex) { @@ -437,7 +568,7 @@ public static async Task Move( if ((position != null && position.Length != 3) || (offset != null && offset.Length != 3)) return ToolResult.Invalid("position and offset take three numbers each"); if (await ui.RunAsync(() => editor.Move(name, position, offset)) is { } refused) return ToolResult.Invalid(refused); - return ToolResult.Json(new { success = true, status = await ui.RunAsync(editor.Status) }); + return ToolResult.Json(new { success = true, status = await StatusOf(editor, ui) }); } catch (Exception ex) { @@ -572,7 +703,7 @@ public static async Task Undo(IEditorSession editor, IUiThreadMarshal ui try { if (await ui.RunAsync(editor.Undo) is { } refused) return ToolResult.Invalid(refused); - return ToolResult.Json(new { success = true, status = await ui.RunAsync(editor.Status) }); + return ToolResult.Json(new { success = true, status = await StatusOf(editor, ui) }); } catch (Exception ex) { @@ -587,7 +718,7 @@ public static async Task Redo(IEditorSession editor, IUiThreadMarshal ui try { if (await ui.RunAsync(editor.Redo) is { } refused) return ToolResult.Invalid(refused); - return ToolResult.Json(new { success = true, status = await ui.RunAsync(editor.Status) }); + return ToolResult.Json(new { success = true, status = await StatusOf(editor, ui) }); } catch (Exception ex) { diff --git a/src/Illusion/Diagnostics/Probes/McpProbes.cs b/src/Illusion/Diagnostics/Probes/McpProbes.cs index c9daf09..0e32cc1 100644 --- a/src/Illusion/Diagnostics/Probes/McpProbes.cs +++ b/src/Illusion/Diagnostics/Probes/McpProbes.cs @@ -59,6 +59,12 @@ internal static class McpProbes "actor_import", "object_properties", "object_set_property", "editor_undo", "editor_redo", "editor_mirror_winter", "object_import", + "editor_target", + "resource_list", + "resource_open", + "resource_status", + "car_tuning", + "car_tuning_set", }; /// Records one assertion. A delegate rather than an Action so the optional diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 6fa4996..1933112 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -34,7 +34,119 @@ internal sealed class AppEditorSession : IEditorSession private static MainWindow? Window => Application.Current.Windows.OfType().FirstOrDefault(); private static D3DImageHost Host => - Window?.Viewport ?? throw new InvalidOperationException(NotOpen); + TargetHost ?? throw new InvalidOperationException(TargetNotOpen); + + // Which editor the scene tools drive: the map editor, or the resource editor once resource_open (or + // editor_target) has pointed them there. Both are the same kind of viewport, so every tool that only works + // on "the scene" — find, select, properties, move, duplicate, delete, camera, screenshot, save, build, + // undo, Blender — works on either; the ones about districts stay with the map. + private static bool _resourceTarget; + + private static ResourceEditorWindow? ResourceWindow => + Application.Current.Windows.OfType().FirstOrDefault(); + + private static D3DImageHost? TargetHost => _resourceTarget ? ResourceWindow?.Stage : Window?.Viewport; + + private static string TargetNotOpen => _resourceTarget + ? "the resource editor is not open — resource_open first" + : NotOpen; + + public string? SetTarget(string target) + { + switch (target.Trim().ToLowerInvariant()) + { + case "map": + _resourceTarget = false; + return Window == null ? "the target is the map now, but the map editor is not open — editor_open_area" : null; + case "resource": + _resourceTarget = true; + return ResourceWindow == null ? "the target is the resource editor now, but it is not open — resource_open" : null; + default: + return $"'{target}' is neither 'map' nor 'resource'"; + } + } + + public ResourceStatus ResourceStatus() + { + if (ResourceWindow is not { } window) + { + return new ResourceStatus(false, _resourceTarget ? "resource" : "map", null, null, false, 0, [], false, [], 0, ""); + } + D3DImageHost stage = window.Stage; + return new ResourceStatus( + Open: true, + Target: _resourceTarget ? "resource" : "map", + Archive: window.StagedEntry?.Name, + ArchivePath: window.StagedEntry?.File.FullName, + Loading: stage.IsLoading, + Meshes: stage.MeshCount, + Selection: stage.SelectedNodes.Select(n => n.Name).ToList(), + UnsavedEdits: stage.HasUnsavedEdits, + PendingBuild: stage.PendingBuildArchives().Select(f => f.Name).ToList(), + BlenderObjects: stage.BridgeEditedCount, + RenderMode: stage.RenderMode.ToString()); + } + + // The game folder, set up from the launcher when no editor has done it yet. + private static string? EnsureEnvironment() + { + if (Assets.MafiaEnvironment.IsInitialized) return null; + return Application.Current.Windows.OfType().FirstOrDefault() is { } launcher + ? launcher.PrepareEnvironment() + : "the game folder is not set yet — open the toolkit's launcher first"; + } + + public string? OpenResource(string archive) + { + if (EnsureEnvironment() is { } notReady) return notReady; + var sds = new FileInfo(Path.IsPathRooted(archive) + ? archive + : Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", archive.EndsWith(".sds", StringComparison.OrdinalIgnoreCase) + ? archive + : archive + ".sds")); + if (!sds.Exists) + { + // A bare name: look it up in the library, the way the browser's search would. + string wanted = Path.GetFileNameWithoutExtension(archive); + Assets.Library.LibraryEntry? hit = Assets.Library.LibraryCatalog + .Build(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds")).AllEntries + .FirstOrDefault(e => string.Equals(e.Name, wanted, StringComparison.OrdinalIgnoreCase)); + if (hit == null) return $"no archive '{archive}' — resource_list finds them"; + sds = hit.File; + } + + if (ResourceWindow == null) + { + // From the launcher the editor takes its place, the way the tile does; beside the map editor it is + // a second window, the way the File menu opens it. + if (Application.Current.Windows.OfType().FirstOrDefault() is { } launcher) + { + if (launcher.OpenResourceEditor() is { } failed) return failed; + } + else + { + new ResourceEditorWindow().Show(); + } + } + if (ResourceWindow is not { } window) return "the resource editor did not open"; + if (window.TargetStage is { } stage && stage.BridgeEditedCount > 0) return "a Blender edit session is open there — blender_end first"; + window.Reveal(sds); + _resourceTarget = true; + return null; + } + + public IReadOnlyList Library(string? query, string? folder, int limit) + { + if (EnsureEnvironment() is { } notReady) throw new InvalidOperationException(notReady); + return Assets.Library.LibraryCatalog.Build(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds")).AllEntries + .Where(e => (query == null || e.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) + && (folder == null || e.FolderPath.Contains(folder, StringComparison.OrdinalIgnoreCase))) + .Take(Math.Max(1, limit)) + .Select(e => new LibraryItem(e.Name, Path.GetRelativePath(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds"), e.File.FullName), + e.Resource.ToString(), e.Size, + File.Exists(Path.Combine(Assets.MafiaEnvironment.ExtractedDir(e.File), "SDSContent.xml")))) + .ToList(); + } public EditorStatus Status() { @@ -164,8 +276,7 @@ public IReadOnlyList Find( public string? Select(IReadOnlyList names) { - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + if (TargetHost is not { } host) return TargetNotOpen; var nodes = new List(names.Count); foreach (string name in names) @@ -181,15 +292,14 @@ public IReadOnlyList Find( public string? RequestBlenderPush() { - if (Window is not { } window) return NotOpen; - if (window.Viewport.BridgeEditedCount == 0) return "no Blender edit session is open — blender_open first"; - return window.Viewport.RequestBridgePush() ? null : "the connection to Blender is gone — blender_open again"; + if (TargetHost is not { } host) return TargetNotOpen; + if (host.BridgeEditedCount == 0) return "no Blender edit session is open — blender_open first"; + return host.RequestBridgePush() ? null : "the connection to Blender is gone — blender_open again"; } public string? OpenInBlender() { - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + if (TargetHost is not { } host) return TargetNotOpen; if (host.BridgeEditedCount > 0) return "a Blender edit session is already open — blender_end first"; if (host.SelectedNodes.Count == 0) return "nothing is selected — scene_select first"; host.OpenInBlender(); @@ -198,9 +308,9 @@ public IReadOnlyList Find( public string? EndBlenderSession() { - if (Window is not { } window) return NotOpen; - if (window.Viewport.BridgeEditedCount == 0) return "no Blender edit session is open"; - window.Viewport.EndBridgeEditSession(); + if (TargetHost is not { } host) return TargetNotOpen; + if (host.BridgeEditedCount == 0) return "no Blender edit session is open"; + host.EndBridgeEditSession(); return null; } @@ -209,10 +319,10 @@ public IReadOnlyList Find( public string? Save(out int filesWritten) { filesWritten = 0; - if (Window is not { } window) return NotOpen; + if (TargetHost is not { } host) return TargetNotOpen; try { - filesWritten = window.Viewport.SaveEdits(); + filesWritten = host.SaveEdits(); return null; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) @@ -282,9 +392,9 @@ public CameraInfo Camera() public string? LookAt(float[] target, float radius, float[]? fromAxis) { - if (Window is not { } window) return NotOpen; + if (TargetHost is not { } host) return TargetNotOpen; if (radius <= 0f) return "radius must be positive"; - window.Viewport.LookAt( + host.LookAt( new Vector3(target[0], target[1], target[2]), radius, fromAxis != null ? new Vector3(fromAxis[0], fromAxis[1], fromAxis[2]) : null); @@ -293,11 +403,11 @@ public CameraInfo Camera() public string? SetCamera(float[] position, float[] target) { - if (Window is not { } window) return NotOpen; + if (TargetHost is not { } host) return TargetNotOpen; var eye = new Vector3(position[0], position[1], position[2]); var at = new Vector3(target[0], target[1], target[2]); if (Vector3.DistanceSquared(eye, at) < 1e-6f) return "position and target are the same point"; - window.Viewport.LookFrom(eye, at); + host.LookFrom(eye, at); return null; } @@ -305,8 +415,13 @@ public CameraInfo Camera() public string? SetView(string? renderMode, bool? collision, bool? crash, bool? zones, bool? navigation) { - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + if (TargetHost is not { } host) return TargetNotOpen; + Window? owner = _resourceTarget ? ResourceWindow : Window; + if (_resourceTarget && (collision != null || crash != null || zones != null || navigation != null)) + { + return "the collision, crash, zone and navigation layers belong to the map — the resource editor draws " + + "an archive's own collision from its Render tab"; + } RenderMode mode = default; if (renderMode != null && !Enum.TryParse(renderMode, ignoreCase: true, out mode)) @@ -318,11 +433,12 @@ public CameraInfo Camera() { // The mode buttons are nameless (they sit inside a strip with its own namescope) and carry the // mode in Tag; checking the right one is what moves the viewport, through the window's handler. - RadioButton? button = Descendants(window) + RadioButton? button = owner == null ? null : Descendants(owner) .FirstOrDefault(b => b.Tag is string tag && tag == mode.ToString()); if (button != null) button.IsChecked = true; host.RenderMode = mode; } + if (Window is not { } window) return null; if (zones is { } showZones) window.ZonesToggle.IsChecked = showZones; if (crash is { } showCrash) window.CrashToggle.IsChecked = showCrash; if (collision is { } showCollision) window.CollisionToggle.IsChecked = showCollision; @@ -332,8 +448,8 @@ public CameraInfo Camera() public string? Screenshot(string path, int width, int height) { - if (Window is not { } window) return NotOpen; - if (window.Viewport.CaptureFrame(width, height) is not { } pixels) return "the viewport is not rendering yet"; + if (TargetHost is not { } host) return TargetNotOpen; + if (host.CaptureFrame(width, height) is not { } pixels) return "the viewport is not rendering yet"; try { if (Path.GetDirectoryName(Path.GetFullPath(path)) is { Length: > 0 } folder) Directory.CreateDirectory(folder); @@ -354,8 +470,7 @@ public CameraInfo Camera() public string? Move(string name, float[]? position, float[]? offset) { - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + if (TargetHost is not { } host) return TargetNotOpen; if (Resolve(host, name, out SceneNode? node) is { } unresolved) return unresolved; if (node!.Source is not IFrameNode frame) return $"'{name}' is a {node.Kind} — it has no transform to move"; if (host.BridgeEditedCount > 0 && !host.BridgeSession.IsEditedNode(node)) @@ -428,8 +543,7 @@ public CameraInfo Camera() public string? DuplicateSelected(out IReadOnlyList copies) { copies = []; - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + if (TargetHost is not { } host) return TargetNotOpen; if (host.BridgeEditedCount > 0) return "a Blender edit session is open — blender_end first"; if (!host.CanDuplicateSelection()) return "nothing in the selection can be duplicated"; var before = new HashSet(host.SelectedNodes); @@ -459,8 +573,7 @@ public IReadOnlyList Properties(string name) public string? SetProperty(string name, string propertyId, string value) { - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + if (TargetHost is not { } host) return TargetNotOpen; if (Resolve(host, name, out SceneNode? node) is { } unresolved) return unresolved; if (node!.Source is not Domain.Properties.IPropertySource source) return $"'{name}' has no properties"; Domain.Properties.PropertyDescriptor? property = source.GetPropertyGroups() @@ -535,8 +648,7 @@ private static bool TryParse(Domain.Properties.PropertyDescriptor p, string text public string? DeleteSelected(out int deleted) { deleted = 0; - if (Window is not { } window) return NotOpen; - D3DImageHost host = window.Viewport; + if (TargetHost is not { } host) return TargetNotOpen; if (host.BridgeEditedCount > 0) return "a Blender edit session is open — blender_end first"; if (!host.CanDeleteSelection()) return "nothing deletable is selected"; deleted = host.SelectedNodes.Count; @@ -544,19 +656,151 @@ private static bool TryParse(Domain.Properties.PropertyDescriptor p, string text return null; } + // The archive the tuning tools work on: the one on the resource editor's stage. + private static FileInfo? TuningArchive(out string? refusal) + { + refusal = null; + if (ResourceWindow?.StagedEntry is not { } entry) + { + refusal = "no archive is on the resource editor's stage — resource_open a car first"; + return null; + } + return entry.File; + } + + private static TuningFieldInfo Describe(int table, Assets.EntityData.TuningBandView band, + Assets.EntityData.TuningElementView element, Assets.EntityData.TuningFieldView field) => + new(table, band.Title, element.Title, field.Label, field.Name, field.Kind.ToString(), field.Value); + + public string? Tuning(int table, string? query, int limit, out IReadOnlyList tables, + out IReadOnlyList fields) + { + tables = []; + fields = []; + if (TuningArchive(out string? refusal) is not { } archive) return refusal; + if (Assets.EntityData.CarTuning.Read(archive) is not { } tuning || tuning.Tables.Count == 0) + { + return $"{archive.Name} carries no tuning table the toolkit can read"; + } + tables = tuning.Tables.Select(t => new TuningTableInfo(t.Index + 1, t.Label, t.TypeName, t.FieldCount)).ToList(); + if (table < 1 || table > tuning.Tables.Count) return $"there is no table {table} — {tuning.Tables.Count} in all"; + + var found = new List(); + foreach (Assets.EntityData.TuningBandView band in tuning.Tables[table - 1].Bands) + { + foreach (Assets.EntityData.TuningElementView element in band.Elements) + { + foreach (Assets.EntityData.TuningFieldView field in element.Rows) + { + if (found.Count >= limit) break; + if (query != null && !field.Name.Contains(query, StringComparison.OrdinalIgnoreCase) + && !field.Label.Contains(query, StringComparison.OrdinalIgnoreCase)) continue; + found.Add(Describe(table, band, element, field)); + } + } + } + fields = found; + return null; + } + + public string? SetTuning(int table, string field, string? band, string? element, string value, out TuningFieldInfo? result) + { + result = null; + if (ResourceWindow is not { } window || TuningArchive(out string? refusal) is not { } archive) + { + return TuningArchive(out string? why) == null ? why : "the resource editor is not open"; + } + if (Assets.EntityData.CarTuning.Read(archive) is not { } tuning) return $"{archive.Name} carries no tuning table"; + if (table < 1 || table > tuning.Tables.Count) return $"there is no table {table} — {tuning.Tables.Count} in all"; + Assets.EntityData.TuningTableView chosen = tuning.Tables[table - 1]; + + var matches = ( + from b in chosen.Bands + where band == null || string.Equals(b.Title, band, StringComparison.OrdinalIgnoreCase) + from e in b.Elements + where element == null || string.Equals(e.Title, element, StringComparison.OrdinalIgnoreCase) + from f in e.Rows + where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) + || string.Equals(f.Label, field, StringComparison.OrdinalIgnoreCase) + || f.Name.EndsWith("." + field, StringComparison.OrdinalIgnoreCase) + select (b, e, f)).ToList(); + if (matches.Count == 0) + { + string where = string.Join(" / ", new[] { band, element }.Where(w => w != null)); + return $"table {table} has no field '{field}'" + (where.Length > 0 ? $" in {where}" : "") + " — car_tuning lists them"; + } + if (matches.Count > 1) + { + return $"'{field}' is in {matches.Count} places — give band and/or element: " + + string.Join("; ", matches.Take(8).Select(m => $"{m.b.Title} / {m.e.Title ?? "-"}")); + } + (Assets.EntityData.TuningBandView inBand, Assets.EntityData.TuningElementView inElement, + Assets.EntityData.TuningFieldView target) = matches[0]; + + IFormatProvider inv = System.Globalization.CultureInfo.InvariantCulture; + const System.Globalization.NumberStyles Num = System.Globalization.NumberStyles.Float; + Assets.EntityData.TuningEditing.TuningValue parsed; + switch (target.Kind) + { + case Assets.EntityData.TuningFieldKind.Number when float.TryParse(value, Num, inv, out float f): + parsed = Assets.EntityData.TuningEditing.TuningValue.Of(f); + break; + case Assets.EntityData.TuningFieldKind.Integer when long.TryParse(value, System.Globalization.NumberStyles.Integer, inv, out long n): + parsed = Assets.EntityData.TuningEditing.TuningValue.Of(n); + break; + case Assets.EntityData.TuningFieldKind.Flag when bool.TryParse(value, out bool flag) || value is "0" or "1": + parsed = Assets.EntityData.TuningEditing.TuningValue.Of(value is "1" || (bool.TryParse(value, out bool b2) && b2) ? 1L : 0L); + break; + case Assets.EntityData.TuningFieldKind.Vector: + string[] parts = value.Split(',', StringSplitOptions.TrimEntries); + if (parts.Length != 3 || !float.TryParse(parts[0], Num, inv, out float x) + || !float.TryParse(parts[1], Num, inv, out float y) || !float.TryParse(parts[2], Num, inv, out float z)) + { + return $"'{value}' is not 'x, y, z'"; + } + parsed = Assets.EntityData.TuningEditing.TuningValue.Of(x, y, z); + break; + case Assets.EntityData.TuningFieldKind.Text: + if (target.Capacity > 0 && value.Length >= target.Capacity) return $"'{value}' is longer than the {target.Capacity - 1} characters the field holds"; + parsed = Assets.EntityData.TuningEditing.TuningValue.Of(value); + break; + default: + return $"'{value}' is not a {target.Kind} value"; + } + + Assets.EntityData.TuningEditing.Change? change = + Assets.EntityData.TuningEditing.Set(chosen.Path, chosen.Index, target.Offset, parsed, target.Name); + if (change == null) return $"{target.Name} could not be written"; + + // The same bookkeeping the Tuning tab does: an undo entry, the archive on the build list, the panel re-read. + D3DImageHost stage = window.TargetStage; + void Reload() => window.Scene.Selection.ReloadTuning(); + stage.History.Push(new TuningValueEdit(change, Reload)); + stage.MarkArchiveModified(archive); + Reload(); + stage.RaiseNotice($"{target.Name} set. Build to write it into the archive.", isError: false); + + Assets.EntityData.TuningFieldView? after = Assets.EntityData.CarTuning.Read(archive)?.Tables[table - 1].Bands + .FirstOrDefault(b => b.Title == inBand.Title)?.Elements + .FirstOrDefault(e => e.Title == inElement.Title)?.Rows + .FirstOrDefault(r => r.Offset == target.Offset); + result = Describe(table, inBand, inElement, after ?? target); + return null; + } + public string? Undo() { - if (Window is not { } window) return NotOpen; - if (!window.Viewport.History.CanUndo) return "nothing to undo"; - window.Viewport.Undo(); + if (TargetHost is not { } host) return TargetNotOpen; + if (!host.History.CanUndo) return "nothing to undo"; + host.Undo(); return null; } public string? Redo() { - if (Window is not { } window) return NotOpen; - if (!window.Viewport.History.CanRedo) return "nothing to redo"; - window.Viewport.Redo(); + if (TargetHost is not { } host) return TargetNotOpen; + if (!host.History.CanRedo) return "nothing to redo"; + host.Redo(); return null; } diff --git a/src/Illusion/ViewModels/SelectionViewModel.cs b/src/Illusion/ViewModels/SelectionViewModel.cs index 6d0021e..5dd082f 100644 --- a/src/Illusion/ViewModels/SelectionViewModel.cs +++ b/src/Illusion/ViewModels/SelectionViewModel.cs @@ -238,6 +238,9 @@ private bool CommitTuningValue( return true; } + /// Re-reads the tuning tables from the working copy — after an edit made from outside the panel. + public void ReloadTuning() => RefreshTuningAfterUndo(); + private void RefreshTuningAfterUndo() { _tuningArchive = null; // defeat the same-archive cache — the file really did change diff --git a/src/Illusion/Views/LauncherWindow.xaml.cs b/src/Illusion/Views/LauncherWindow.xaml.cs index 3c5e437..947b5ff 100644 --- a/src/Illusion/Views/LauncherWindow.xaml.cs +++ b/src/Illusion/Views/LauncherWindow.xaml.cs @@ -459,6 +459,16 @@ private void OpenEditor(Func create) ? TryOpenEditor(() => new MainWindow()) : "The launcher is not ready: the game path is not set, or the game has not been unpacked yet."; + /// The resource editor tile, for the MCP server — the counterpart of . + internal string? OpenResourceEditor() => + MapEditorBtn.IsEnabled + ? TryOpenEditor(() => new ResourceEditorWindow()) + : "The launcher is not ready: the game path is not set, or the game has not been unpacked yet."; + + /// Sets the game folder up from the launcher's path, for a caller that needs it before any editor + /// is open (listing the library). Null when ready. + internal string? PrepareEnvironment() => EnsureEnv(out string? error) ? null : "Could not open the game: " + error; + private string? TryOpenEditor(Func create) { // Initialize with the chosen path BEFORE opening the viewport (it calls TryInitialize again diff --git a/src/Illusion/Views/ResourceEditorWindow.xaml.cs b/src/Illusion/Views/ResourceEditorWindow.xaml.cs index a143ab1..9e90858 100644 --- a/src/Illusion/Views/ResourceEditorWindow.xaml.cs +++ b/src/Illusion/Views/ResourceEditorWindow.xaml.cs @@ -111,6 +111,9 @@ public ResourceEditorWindow() /// The archive currently on the stage, or null before anything has been opened. public LibraryEntry? StagedEntry => _staged; + /// The stage's viewport — what the MCP session drives when the resource editor is its target. + internal Viewport.D3DImageHost TargetStage => Stage; + /// /// Opens the window on a particular archive — what the map editor's "Open in library" jump will use once /// it exists. Safe to call before the catalog has finished building: the request is remembered and From b65132dd2c9efeeb2e56309e33905f7b9c5455c9 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 03:59:04 +0300 Subject: [PATCH 028/102] feat(cars): clone a car under a new name; a Build no longer drops XML - car_clone (MCP) / CarCloner: copies pc\sds\cars\.sds and its winter twin to .sds and renames what the name keys inside - the root frame and its actor link (and so the name table), the PREFAB entry (FNV64 of the name) and the entity-data storage (FNV64 of the name in lower case). Then registers the car: a vehicles.tbl row under a fresh id with the source's class, price and flags, its PaintCombinations row, its AiProps cover points, and - optionally - a slot in every traffic row (CARM*.tbl) that picks the source. Builds the new archives plus tables.sds and ingame.sds, backups kept. - GameTable: an extracted .tbl as rows of cells, written back through the table codec. PrefabFile.Rekey, EntityDataStorageFile.Hash. - Fix: PruneMissingEntries looked for an XML resource under its manifest name, but it sits on disk as name + ".xml" - so the first Build of an archive with XML resources (ingame.sds, tables.sds) unsaid every one of them, and the Script containers with them. XML is now found by its real file, containers are left alone. --probe-car-clone: 33/0 (on scratch copies: refusals write nothing, the rows and hashes the clone gains, the archives pack, nothing is pruned, every XML of both table archives decompiles to the same text after a pack). --probe-mcp 66/0, --probe-object-transplant 38/0. --- README.md | 3 +- src/Illusion.Assets/Cars/CarCloneFolders.cs | 5 + src/Illusion.Assets/Cars/CarCloneOutcome.cs | 13 + src/Illusion.Assets/Cars/CarCloneResult.cs | 5 + src/Illusion.Assets/Cars/CarCloner.cs | 339 ++++++++++++++++++ src/Illusion.Assets/Sds/SdsWriter.cs | 10 +- .../EntityData/EntityDataStorageFile.cs | 7 + .../Prefab/PrefabFile.Rekeying.cs | 19 + .../ResourceFormats/GameTable.cs | 92 +++++ src/Illusion.Mcp/EditorModels.cs | 8 + src/Illusion.Mcp/IEditorSession.cs | 5 + src/Illusion.Mcp/Tools/EditorTools.cs | 21 ++ src/Illusion/Diagnostics/ProbeRunner.cs | 5 + .../Diagnostics/Probes/CarCloneProbes.cs | 229 ++++++++++++ src/Illusion/Diagnostics/Probes/McpProbes.cs | 1 + src/Illusion/Mcp/AppEditorSession.cs | 17 + 16 files changed, 776 insertions(+), 3 deletions(-) create mode 100644 src/Illusion.Assets/Cars/CarCloneFolders.cs create mode 100644 src/Illusion.Assets/Cars/CarCloneOutcome.cs create mode 100644 src/Illusion.Assets/Cars/CarCloneResult.cs create mode 100644 src/Illusion.Assets/Cars/CarCloner.cs create mode 100644 src/Illusion.Formats/Prefab/PrefabFile.Rekeying.cs create mode 100644 src/Illusion.Formats/ResourceFormats/GameTable.cs create mode 100644 src/Illusion/Diagnostics/Probes/CarCloneProbes.cs diff --git a/README.md b/README.md index 0202840..aad6d12 100644 --- a/README.md +++ b/README.md @@ -260,7 +260,8 @@ and nothing reaches the game before `editor_build` (which keeps the usual timest `viewport_screenshot` is how a client checks what a push actually looks like. `editor_target resource` points the same tools at the resource editor's stage (one archive, such as a car, opened with `resource_open`); `car_tuning` and `car_tuning_set` read and edit that car's -entity-data tables the way the Tuning tab does. +entity-data tables the way the Tuning tab does; \car_clone\ makes a new car out of an existing one +(a renamed copy of its archive, registered in the vehicle, paint, cover-point and traffic tables). Two of the file tools are worth knowing about before you rely on them. `edit_stream_map` is the only one that writes: it previews by default (`dryRun` is true unless you say otherwise), keeps a diff --git a/src/Illusion.Assets/Cars/CarCloneFolders.cs b/src/Illusion.Assets/Cars/CarCloneFolders.cs new file mode 100644 index 0000000..29400de --- /dev/null +++ b/src/Illusion.Assets/Cars/CarCloneFolders.cs @@ -0,0 +1,5 @@ +namespace Illusion.Assets.Cars; + +/// The working copies a car clone writes: tables.sds and ingame.sds, and each car archive (the +/// car and its winter twin) with the folder its copy goes to. +public sealed record CarCloneFolders(string Tables, string Ingame, IReadOnlyList<(string From, string To)> Cars); diff --git a/src/Illusion.Assets/Cars/CarCloneOutcome.cs b/src/Illusion.Assets/Cars/CarCloneOutcome.cs new file mode 100644 index 0000000..5c7d863 --- /dev/null +++ b/src/Illusion.Assets/Cars/CarCloneOutcome.cs @@ -0,0 +1,13 @@ +namespace Illusion.Assets.Cars; + +/// +/// What cloning a car wrote: the new model name and the vehicle id the tables give it, the archives built (the +/// car, its winter twin, the two table archives) with the backup taken of each one that already existed, how many +/// traffic rows now pick the clone, and what was left out and why. +/// +public sealed record CarCloneOutcome( + string Name, + int VehicleId, + IReadOnlyList<(string Archive, string? Backup)> Packed, + int TrafficRows, + IReadOnlyList Notes); diff --git a/src/Illusion.Assets/Cars/CarCloneResult.cs b/src/Illusion.Assets/Cars/CarCloneResult.cs new file mode 100644 index 0000000..0bff6d8 --- /dev/null +++ b/src/Illusion.Assets/Cars/CarCloneResult.cs @@ -0,0 +1,5 @@ +namespace Illusion.Assets.Cars; + +/// What a clone wrote into the working copies: the new vehicle id, the traffic rows that now pick it, +/// and what was left out. +public sealed record CarCloneResult(int VehicleId, int TrafficRows, IReadOnlyList Notes); diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs new file mode 100644 index 0000000..00f2454 --- /dev/null +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -0,0 +1,339 @@ +using System.Text.RegularExpressions; +using Illusion.Assets.Sds; +using Illusion.Formats.Archive; +using Illusion.Formats.EntityData; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Hashing; +using Illusion.Formats.Prefab; +using Illusion.Formats.ResourceFormats; + +namespace Illusion.Assets.Cars; + +/// +/// Makes a new car out of an existing one: the same model, tuning and sounds under a new name, registered so the +/// game knows it. +/// +/// +/// What ties a car together, measured on the shipped cars: vehicles.tbl (tables.sds) lists it by id and +/// model name, and the game loads /sds/cars/<name in lower case>.sds for it. Inside, the root +/// frame carries the model name, the PREFAB entry (seats, doors, wheels, deformation) is keyed by FNV64 of that +/// name, and the entity-data storage (the tuning tables) by FNV64 of the name in lower case. Paint combinations +/// (PaintCombinations.tbl) and the cover points around the car (AiProps/<name>.xml) live in +/// ingame.sds under the id and the name; traffic (CARM*.tbl) picks cars by id. Everything else inside the +/// archive — buffers, textures, sounds — is addressed archive-locally or shared, and stays as it is. +/// +/// +public static partial class CarCloner +{ + /// A model name fits a String32 cell with its terminator. + public const int MaxNameLength = 31; + + private const int VehicleIdColumn = 0; + private const int VehicleNameColumn = 2; + private const int PaintIdColumn = 0; + private const int PaintNameColumn = 1; + private const int TrafficCountColumn = 1; + private const int TrafficFirstIdColumn = 2; + + [GeneratedRegex("^[A-Za-z][A-Za-z0-9_]*$")] + private static partial Regex NamePattern(); + + /// Why cannot be a model name, or null when it can. + public static string? Refuse(string name) + { + if (string.IsNullOrWhiteSpace(name)) return "give the new car a name"; + if (name.Length > MaxNameLength) return $"'{name}' is longer than the {MaxNameLength} characters a model name holds"; + if (!NamePattern().IsMatch(name)) return $"'{name}' — a model name is a letter, then letters, digits and '_'"; + return null; + } + + /// + /// Clones a car of the game the toolkit has open, then builds the new archives and the two table archives + /// (keeping a backup of each that existed). is the car's archive or model name + /// (shubert_38). With , every traffic row that can pick the source car can + /// pick the clone as well. + /// + public static CarCloneOutcome? Clone(string source, string name, bool traffic, out string? refusal) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(name); + refusal = Refuse(name); + if (refusal != null) return null; + if (!MafiaEnvironment.IsInitialized) + { + refusal = "the game folder is not set"; + return null; + } + + string sds = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + var tablesSds = new FileInfo(Path.Combine(sds, "tables", "tables.sds")); + var ingameSds = new FileInfo(Path.Combine(sds, "tables", "ingame.sds")); + string stem = Path.GetFileNameWithoutExtension(source).ToLowerInvariant(); + var cars = new List<(FileInfo From, FileInfo To)>(); + foreach (string suffix in new[] { "", "_z" }) + { + var from = new FileInfo(Path.Combine(sds, "cars", stem + suffix + ".sds")); + var to = new FileInfo(Path.Combine(sds, "cars", name.ToLowerInvariant() + suffix + ".sds")); + if (!from.Exists) + { + if (suffix.Length == 0) + { + refusal = $"there is no car archive {from.Name} in pc\\sds\\cars"; + return null; + } + continue; + } + if (to.Exists) + { + refusal = $"{to.Name} already exists — pick another name"; + return null; + } + cars.Add((from, to)); + } + if (!tablesSds.Exists || !ingameSds.Exists) + { + refusal = "the game's tables.sds / ingame.sds are not where they belong (pc\\sds\\tables)"; + return null; + } + + var folders = new CarCloneFolders( + SdsMeshLoader.EnsureExtracted(tablesSds), + SdsMeshLoader.EnsureExtracted(ingameSds), + [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.ExtractedDir(c.To)))]); + + // A folder left behind by an attempt that never produced its archive is nobody's working copy. + foreach ((string _, string to) in folders.Cars) SdsWriter.DeleteExtracted(to); + + CarCloneResult? result = CloneExtracted(folders, stem, name, traffic, out refusal); + if (result == null) return null; + + DateTime when = DateTime.Now; + var packed = new List<(string Archive, string? Backup)>(); + foreach ((FileInfo _, FileInfo to) in cars) + { + SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: false, when); + packed.Add((made.Archive, made.Backup)); + } + foreach (FileInfo table in new[] { tablesSds, ingameSds }) + { + SdsWriter.PackResult made = SdsWriter.PackSds(table, createBackup: true, when); + packed.Add((made.Archive, made.Backup)); + } + return new CarCloneOutcome(name, result.VehicleId, packed, result.TrafficRows, result.Notes); + } + + /// + /// The clone itself, on working copies only: copies each car folder to its new place and renames what the + /// name keys there, then adds the vehicle, its paint combinations, its cover points and (optionally) its + /// traffic slots to the table folders. Nothing is packed. Refuses before writing anything when the source + /// car is not in the vehicle table or the name is already taken. + /// + public static CarCloneResult? CloneExtracted( + CarCloneFolders folders, string source, string name, bool traffic, out string? refusal) + { + ArgumentNullException.ThrowIfNull(folders); + refusal = Refuse(name); + if (refusal != null) return null; + + string vehiclesPath = Path.Combine(folders.Tables, "tables", "vehicles.tbl"); + if (!File.Exists(vehiclesPath)) + { + refusal = "tables.sds has no vehicles.tbl"; + return null; + } + GameTable vehicles = GameTable.Load(vehiclesPath); + int sourceRow = vehicles.FindRow(VehicleNameColumn, source); + if (sourceRow < 0) + { + refusal = $"vehicles.tbl has no car named '{source}'"; + return null; + } + if (vehicles.FindRow(VehicleNameColumn, name) >= 0) + { + refusal = $"vehicles.tbl already has a car named '{name}'"; + return null; + } + foreach ((string from, string to) in folders.Cars) + { + if (!File.Exists(Path.Combine(from, "SDSContent.xml"))) + { + refusal = $"{from} is not an extracted archive"; + return null; + } + if (Directory.Exists(to)) + { + refusal = $"{to} already exists"; + return null; + } + } + + string model = (string)vehicles.Cell(sourceRow, VehicleNameColumn); + int sourceId = (int)vehicles.Cell(sourceRow, VehicleIdColumn); + int id = 0; + for (int i = 0; i < vehicles.RowCount; i++) id = Math.Max(id, (int)vehicles.Cell(i, VehicleIdColumn) + 1); + var notes = new List(); + + // The archives first: they are new folders, so a failure there leaves the tables as they were. + foreach ((string from, string to) in folders.Cars) + { + CopyTree(from, to); + notes.AddRange(RenameInside(to, model, name)); + } + + int added = vehicles.CopyRow(sourceRow); + vehicles.SetCell(added, VehicleIdColumn, id); + vehicles.SetCell(added, VehicleNameColumn, name); + + string paintPath = Path.Combine(folders.Ingame, "tables", "PaintCombinations.tbl"); + GameTable? paint = File.Exists(paintPath) ? GameTable.Load(paintPath) : null; + int paintRow = paint?.FindRow(PaintNameColumn, model) ?? -1; + if (paint != null && paintRow >= 0) + { + int copy = paint.CopyRow(paintRow); + paint.SetCell(copy, PaintIdColumn, id); + paint.SetCell(copy, PaintNameColumn, name); + } + else + { + notes.Add($"PaintCombinations.tbl has no row for {model} — the clone takes the game's default colours"); + } + + int trafficRows = 0; + var trafficTables = new List<(string Path, GameTable Table)>(); + if (traffic) + { + foreach (string path in Directory.EnumerateFiles(Path.Combine(folders.Ingame, "tables"), "CARM*.tbl")) + { + GameTable table = GameTable.Load(path); + int rows = AddToTraffic(table, sourceId, id); + if (rows == 0) continue; + trafficRows += rows; + trafficTables.Add((path, table)); + } + } + + AtomicFile.WriteAllBytes(vehiclesPath, vehicles.ToBytes()); + if (paint != null && paintRow >= 0) AtomicFile.WriteAllBytes(paintPath, paint.ToBytes()); + foreach ((string path, GameTable table) in trafficTables) AtomicFile.WriteAllBytes(path, table.ToBytes()); + if (!CopyCoverPoints(folders.Ingame, model, name)) + { + notes.Add($"ingame.sds has no AiProps/{model} — the clone has no cover points around it"); + } + + refusal = null; + return new CarCloneResult(id, trafficRows, notes); + } + + // Every traffic row that can pick the source car can pick the clone too: a row is a weight, a count and + // that many vehicle ids, in a fixed number of columns. + private static int AddToTraffic(GameTable table, int sourceId, int id) + { + int rows = 0; + for (int row = 0; row < table.RowCount; row++) + { + int count = (int)table.Cell(row, TrafficCountColumn); + if (TrafficFirstIdColumn + count >= table.ColumnCount) continue; + bool picksSource = false; + for (int i = 0; i < count; i++) + { + if ((int)table.Cell(row, TrafficFirstIdColumn + i) == sourceId) picksSource = true; + } + if (!picksSource) continue; + table.SetCell(row, TrafficFirstIdColumn + count, id); + table.SetCell(row, TrafficCountColumn, count + 1); + rows++; + } + return rows; + } + + private static bool CopyCoverPoints(string ingame, string model, string name) + { + string from = $"/tables/AiProps/{model}"; + string to = $"/tables/AiProps/{name}"; + SdsManifest manifest = SdsManifest.Load(ingame); + IReadOnlyList<(string Name, string Value)>? fields = manifest.EntryFields(from); + string source = Path.Combine(ingame, "tables", "AiProps", model + ".xml"); + if (fields is not { Count: >= 3 } || fields[^1].Name != "Version" + || !int.TryParse(fields[^1].Value, out int version) || !File.Exists(source)) + { + return false; + } + string text = File.ReadAllText(source).Replace($"name=\"{model}\"", $"name=\"{name}\"", StringComparison.Ordinal); + AtomicFile.WriteAllBytes(Path.Combine(ingame, "tables", "AiProps", name + ".xml"), + System.Text.Encoding.UTF8.GetBytes(text)); + manifest.AddEntry(fields[0].Value, to, version, [.. fields.Skip(2).Take(fields.Count - 3)]); + return true; + } + + // The root frame takes the new name (and with it the name table), the prefab entry and the entity-data + // storage follow it under its new hash. + private static List RenameInside(string folder, string model, string name) + { + var notes = new List(); + SdsManifest manifest = SdsManifest.Load(folder); + string label = Path.GetFileName(folder); + + IReadOnlyList frames = manifest.GetFiles("FrameResource"); + // Loaded with its name table, which is what marks the frames the rebuilt table has to list. + if (frames.Count > 0 && ExtractedSds.Load(folder).FrameResource is { } frame) + { + FrameObjectBase? root = frame.FrameObjects.Values.OfType() + .FirstOrDefault(f => string.Equals(f.Name.String, model, StringComparison.OrdinalIgnoreCase)); + if (root != null) + { + root.Name = new HashName(name); + // The root is a Frame whose actor link names the car as well. + if (root is FrameObjectFrame linked + && string.Equals(linked.ActorHash?.String, model, StringComparison.OrdinalIgnoreCase)) + { + linked.ActorHash = new HashName(name); + } + AtomicFile.WriteAllBytes(frames[0], frame.WriteToStream()); + IReadOnlyList tables = manifest.GetFiles("FrameNameTable"); + if (tables.Count > 0) + { + var table = new FrameNameTable(); + table.BuildDataFromResource(frame); + using var ms = new MemoryStream(); + using (var writer = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) table.WriteToFile(writer); + AtomicFile.WriteAllBytes(tables[0], ms.ToArray()); + } + } + else + { + notes.Add($"{label}: no frame is named {model} — the root keeps its name"); + } + } + + foreach (string path in manifest.GetFiles("PREFAB")) + { + PrefabFile prefab = PrefabFile.Load(path); + if (prefab.Rekey(Fnv64.Hash(model), Fnv64.Hash(name))) AtomicFile.WriteAllBytes(path, prefab.ToBytes()); + else notes.Add($"{label}: the prefab has no entry for {model}"); + } + + foreach (string path in manifest.GetFiles("EntityDataStorage")) + { + EntityDataStorageFile storage = EntityDataStorageFile.Load(path); + if (storage.Hash != Fnv64.Hash(model.ToLowerInvariant())) + { + notes.Add($"{label}: the entity data is not filed under {model.ToLowerInvariant()}"); + continue; + } + storage.Hash = Fnv64.Hash(name.ToLowerInvariant()); + AtomicFile.WriteAllBytes(path, storage.ToBytes()); + } + return notes; + } + + private static void CopyTree(string from, string to) + { + foreach (string file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(to, Path.GetRelativePath(from, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + } +} diff --git a/src/Illusion.Assets/Sds/SdsWriter.cs b/src/Illusion.Assets/Sds/SdsWriter.cs index 2e34c68..9ef1e2b 100644 --- a/src/Illusion.Assets/Sds/SdsWriter.cs +++ b/src/Illusion.Assets/Sds/SdsWriter.cs @@ -45,13 +45,19 @@ internal static List PruneMissingEntries(string extracted) try { manifest = SdsManifest.Load(extracted); } catch (Exception ex) when (ex is IOException or SdsFormatException) { return dropped; } - foreach ((string _, string file) in manifest.Entries.ToArray()) + foreach ((string type, string file) in manifest.Entries.ToArray()) { + // Containers list their pieces under elements of their own; their File is not a file. + if (type is "Script" or "Table") continue; // Names out of the archive are often rooted ("/missions/city11_Little_Italy/SoundSectors_city11.bin"), // and Path.Combine treats a rooted second argument as absolute: the file was looked for at the // drive root, never found, and a perfectly present resource was unsaid on every Build — a // district lost its AudioSectors that way. Joined the way the handlers join it. - if (File.Exists(Path.Combine(extracted, file.TrimStart('/', '\\')))) continue; + string onDisk = Path.Combine(extracted, file.TrimStart('/', '\\')); + if (File.Exists(onDisk)) continue; + // An XML resource is named without its extension and extracts to that name plus ".xml" — the + // handler adds it on both sides. Missing that unsaid every XML of ingame.sds on its first Build. + if (type == "XML" && File.Exists(onDisk + ".xml")) continue; if (manifest.RemoveEntry(file)) dropped.Add(file); } return dropped; diff --git a/src/Illusion.Formats/EntityData/EntityDataStorageFile.cs b/src/Illusion.Formats/EntityData/EntityDataStorageFile.cs index 29440a7..e4c5990 100644 --- a/src/Illusion.Formats/EntityData/EntityDataStorageFile.cs +++ b/src/Illusion.Formats/EntityData/EntityDataStorageFile.cs @@ -22,6 +22,13 @@ public sealed class EntityDataStorageFile /// The entity type as the known enum (an unmapped id keeps its numeric value). public EntityType Type => (EntityType)Wire.EntityType; + /// The name hash the storage is filed under — for a car, FNV64 of its model name in lower case. + public ulong Hash + { + get => Wire.Hash; + set => Wire.Hash = value; + } + /// Number of entity-data tables. public int TableCount => Wire.TableHashes.Count; diff --git a/src/Illusion.Formats/Prefab/PrefabFile.Rekeying.cs b/src/Illusion.Formats/Prefab/PrefabFile.Rekeying.cs new file mode 100644 index 0000000..79b6845 --- /dev/null +++ b/src/Illusion.Formats/Prefab/PrefabFile.Rekeying.cs @@ -0,0 +1,19 @@ +namespace Illusion.Formats.Prefab; + +public sealed partial class PrefabFile +{ + /// + /// Files the entry kept under under instead — what a copy of a + /// model under a new name needs, since an entity finds its init data by the hash of its own name. + /// + /// False when there is no such entry, or is already taken. + public bool Rekey(ulong from, ulong to) + { + if (from == to) return Contains(from); + if (Contains(to)) return false; + Native.Model.PrefabEntryW? entry = Wire.Prefabs.FirstOrDefault(p => p.Hash == from); + if (entry == null) return false; + entry.Hash = to; + return true; + } +} diff --git a/src/Illusion.Formats/ResourceFormats/GameTable.cs b/src/Illusion.Formats/ResourceFormats/GameTable.cs new file mode 100644 index 0000000..55468ae --- /dev/null +++ b/src/Illusion.Formats/ResourceFormats/GameTable.cs @@ -0,0 +1,92 @@ +using Illusion.Formats.Hashing; + +namespace Illusion.Formats.ResourceFormats; + +/// +/// One data table as an archive's working copy holds it: a .tbl file under tables\, which is the +/// Table entry's version as a dword followed by that one table's body. Cells read back as the .NET types the +/// table reader produces (int, uint, float, ulong, bool, string); a row is written back through the same codec, +/// so a table edited here packs like one that was never touched. +/// +public sealed class GameTable +{ + private readonly TableData _data; + + private GameTable(ushort version, TableData data) + { + Version = version; + _data = data; + } + + /// The Table entry's format version the file carries. + public ushort Version { get; } + + public int ColumnCount => _data.Columns.Count; + + public int RowCount => _data.Rows.Count; + + /// The column's cell type, by its format name (Signed32, Flags32, String32, Hash64AndString32…). + public string ColumnType(int column) => _data.Columns[column].Type.ToString(); + + public object Cell(int row, int column) => _data.Rows[row].Values[column]; + + /// Sets one cell. The value must already be the type the column reads back as. + public void SetCell(int row, int column, object value) + { + ArgumentNullException.ThrowIfNull(value); + Type expected = TableData.GetValueTypeForColumnType(_data.Columns[column].Type); + if (value.GetType() != expected) + { + throw new ArgumentException( + $"column {column} holds {expected.Name}, not {value.GetType().Name}", nameof(value)); + } + _data.Rows[row].Values[column] = value; + } + + /// Appends a copy of a row and returns the copy's index. + public int CopyRow(int row) + { + var copy = new TableData.Row(); + copy.Values.AddRange(_data.Rows[row].Values); + _data.Rows.Add(copy); + return _data.Rows.Count - 1; + } + + /// The first row whose cell in is the text , + /// ignoring case, or -1. + public int FindRow(int column, string text) + { + for (int i = 0; i < _data.Rows.Count; i++) + { + if (_data.Rows[i].Values[column] is string value + && string.Equals(value, text, StringComparison.OrdinalIgnoreCase)) + { + return i; + } + } + return -1; + } + + public static GameTable Load(string path) => Read(File.ReadAllBytes(path)); + + public static GameTable Read(byte[] bytes) + { + ArgumentNullException.ThrowIfNull(bytes); + if (bytes.Length < sizeof(uint)) throw new FormatException("a .tbl starts with its version dword"); + ushort version = (ushort)BitConverter.ToUInt32(bytes, 0); + TableData data = TableResource.DecodeSingleTable(version, bytes[sizeof(uint)..]); + return new GameTable(version, data); + } + + public byte[] ToBytes() + { + byte[] body = TableResource.EncodeSingleTable(Version, _data); + var bytes = new byte[sizeof(uint) + body.Length]; + BitConverter.TryWriteBytes(bytes, (uint)Version); + body.CopyTo(bytes, sizeof(uint)); + return bytes; + } + + /// The hash a Hash64AndString32 cell stores beside its text — FNV64 of the text as written. + public static ulong NameHash(string text) => Fnv64.Hash(text); +} diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index b04439e..e07f355 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -32,6 +32,14 @@ public sealed record ResourceStatus( /// holds (Car, Character, CityCrash, …), its size, and whether it already has a working copy. public sealed record LibraryItem(string Name, string Path, string Kind, long Size, bool Extracted); +/// A car cloned under a new name: the vehicle id the tables gave it, how many traffic rows pick it, +/// each archive written with the backup taken of it (null for a new one), and what was left out. +public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, + IReadOnlyList Packed, IReadOnlyList Notes); + +/// One archive a car clone wrote, and the backup of what it replaced. +public sealed record PackedArchive(string Archive, string? Backup); + /// One entity-data table of a car — a car ships several (the stock one and its tuned variants); the /// label names its mass and power, which is what tells them apart. public sealed record TuningTableInfo(int Table, string Label, string Type, int Fields); diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index af5098c..52064d6 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -134,6 +134,11 @@ public interface IEditorSession /// wheel has the same fields). Null on success. string? SetTuning(int table, string field, string? band, string? element, string value, out TuningFieldInfo? result); + /// Clones a car under a new model name and registers it (vehicle table, paint, cover points and, + /// with , the traffic rows of the source car), then builds the archives. Null on + /// success. + string? CloneCar(string source, string name, bool traffic, out CarCloneInfo? result); + string? Undo(); string? Redo(); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index c611fbf..50b596e 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -162,6 +162,27 @@ public static async Task CarTuningSet( } } + [McpServerTool(Name = "car_clone")] + [Description("Make a new car out of an existing one for single player: copies pc\\sds\\cars\\.sds (and its winter _z twin) to .sds with the root frame, prefab entry and entity data renamed, adds the car to vehicles.tbl under a new id (class, price and flags of the source), to PaintCombinations.tbl and AiProps, and — with traffic — to every traffic row that can pick the source. Then builds the new archives plus tables.sds and ingame.sds (backups kept). The game must not be running. Open the clone with resource_open and tune it with car_tuning_set.")] + public static async Task CarClone( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The car to copy, by archive or model name, e.g. 'shubert_38'.")] string source, + [Description("The new model name: a letter, then letters, digits and '_', at most 31 characters, e.g. 'Shubert_38_Sport'.")] string name, + [Description("Let traffic pick the clone wherever it picks the source. Default true.")] bool traffic = true) + { + try + { + CarCloneInfo? result = null; + string? refused = await ui.RunAsync(() => editor.CloneCar(source, name, traffic, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, clone = result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + [McpServerTool(Name = "editor_list_areas")] [Description("Names of the areas (districts and interiors) the map editor can load. Empty until the editor is open — editor_open_area opens it.")] public static async Task ListAreas(IEditorSession editor, IUiThreadMarshal ui) diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 6940ad2..9db1f74 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -173,6 +173,11 @@ public static bool TryRun(string[] args) case "--probe-prop-catalog": PropCatalogProbes.RunPropCatalogProbe(); return true; + // A car cloned under a new name, on scratch copies of the working copies. + // Output: %TEMP%\illusion_car_clone.txt + case "--probe-car-clone": + CarCloneProbes.RunCarCloneProbe(); + return true; // Buffers nothing draws from: none in the archives as shipped, how many in the working copies. // Output: %TEMP%\illusion_geometry_sweep.txt case "--probe-geometry-sweep": diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs new file mode 100644 index 0000000..d118f80 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -0,0 +1,229 @@ +using System.IO; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Cars; +using Illusion.Assets.Sds; +using Illusion.Formats; +using Illusion.Formats.Archive; +using Illusion.Formats.EntityData; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Hashing; +using Illusion.Formats.Prefab; +using Illusion.Formats.ResourceFormats; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Cloning a car, on scratch copies of the install's working copies (nothing of the game is written): the +/// vehicle, paint, cover-point and traffic rows the clone gains, the renamed root frame, prefab entry and +/// entity data, that every folder still packs, and that the toolkit reads the clone as a car. +/// +internal static class CarCloneProbes +{ + private const string Source = "shubert_38"; + private const string Name = "Shubert_38_Clone"; + + // Output: %TEMP%\illusion_car_clone.txt + internal static void RunCarCloneProbe() + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_clone.txt"); + string scratch = Path.Combine(Path.GetTempPath(), "illusion_car_clone"); + 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)}"); + } + + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); + + string sds = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + string tables = Copy(SdsMeshLoader.EnsureExtracted(new FileInfo(Path.Combine(sds, "tables", "tables.sds"))), + Path.Combine(scratch, "tables")); + string ingame = Copy(SdsMeshLoader.EnsureExtracted(new FileInfo(Path.Combine(sds, "tables", "ingame.sds"))), + Path.Combine(scratch, "ingame")); + var cars = new List<(string From, string To)>(); + foreach (string suffix in new[] { "", "_z" }) + { + var archive = new FileInfo(Path.Combine(sds, "cars", Source + suffix + ".sds")); + if (archive.Exists) cars.Add((SdsMeshLoader.EnsureExtracted(archive), Path.Combine(scratch, "car" + suffix))); + } + var folders = new CarCloneFolders(tables, ingame, cars); + string vehiclesPath = Path.Combine(tables, "tables", "vehicles.tbl"); + GameTable before = GameTable.Load(vehiclesPath); + int sourceRow = before.FindRow(2, Source); + int sourceId = (int)before.Cell(sourceRow, 0); + + Check("refuses a name that is not a model name", + CarCloner.CloneExtracted(folders, Source, "9 bad name", traffic: true, out string? bad) == null && bad != null, bad ?? ""); + Check("refuses a car the vehicle table does not list", + CarCloner.CloneExtracted(folders, "no_such_car", Name, traffic: true, out string? none) == null && none != null, none ?? ""); + Check("refuses a name the table already has", + CarCloner.CloneExtracted(folders, Source, "Smith_V8", traffic: true, out string? taken) == null && taken != null, taken ?? ""); + Check("a refusal writes nothing", !Directory.Exists(cars[0].To)); + + CarCloneResult? result = CarCloner.CloneExtracted(folders, Source, Name, traffic: true, out string? refused); + Check("clones shubert_38", result != null, refused ?? ""); + if (result == null) return; + foreach (string note in result.Notes) sb.AppendLine(" note: " + note); + + GameTable vehicles = GameTable.Load(vehiclesPath); + int row = vehicles.FindRow(2, Name); + Check("vehicles.tbl gains one row", vehicles.RowCount == before.RowCount + 1 && row >= 0); + Check("under a fresh id", row >= 0 && (int)vehicles.Cell(row, 0) == result.VehicleId + && Enumerable.Range(0, before.RowCount).All(i => (int)before.Cell(i, 0) != result.VehicleId), $"id {result.VehicleId}"); + Check("with the source car's class, price and flags", + row >= 0 && Enumerable.Range(0, vehicles.ColumnCount).Where(c => c is not 0 and not 2) + .All(c => Equals(vehicles.Cell(row, c), before.Cell(sourceRow, c)))); + byte[] raw = File.ReadAllBytes(vehiclesPath); + Check("the name cell stores FNV64 of the name beside it", + IndexOf(raw, BitConverter.GetBytes(Fnv64.Hash(Name))) >= 0); + Check("the source row is untouched", + Enumerable.Range(0, vehicles.ColumnCount).All(c => Equals(vehicles.Cell(sourceRow, c), before.Cell(sourceRow, c)))); + + GameTable paint = GameTable.Load(Path.Combine(ingame, "tables", "PaintCombinations.tbl")); + int paintRow = paint.FindRow(1, Name); + Check("PaintCombinations.tbl lists the clone under its id", + paintRow >= 0 && (int)paint.Cell(paintRow, 0) == result.VehicleId); + + string cover = Path.Combine(ingame, "tables", "AiProps", Name + ".xml"); + Check("cover points copied under the new name", + File.Exists(cover) && File.ReadAllText(cover).Contains($"name=\"{Name}\"", StringComparison.Ordinal) + && SdsManifest.Load(ingame).HasFile($"/tables/AiProps/{Name}")); + + int trafficRows = 0; + bool consistent = true; + foreach (string path in Directory.EnumerateFiles(Path.Combine(ingame, "tables"), "CARM*.tbl")) + { + GameTable table = GameTable.Load(path); + for (int r = 0; r < table.RowCount; r++) + { + int count = (int)table.Cell(r, 1); + var ids = Enumerable.Range(0, count).Select(i => (int)table.Cell(r, 2 + i)).ToList(); + if (!ids.Contains(result.VehicleId)) continue; + trafficRows++; + consistent &= ids.Contains(sourceId) && ids[^1] == result.VehicleId; + } + } + Check("traffic picks the clone wherever it picks the source", trafficRows == result.TrafficRows && trafficRows > 0 && consistent, + $"{trafficRows} rows"); + + foreach ((string _, string to) in cars) + { + string label = Path.GetFileName(to); + SdsManifest manifest = SdsManifest.Load(to); + var frame = new FrameResource(manifest.GetFiles("FrameResource")[0]); + var names = frame.FrameObjects.Values.OfType().Select(f => f.Name.String).ToList(); + Check($"{label}: the root frame carries the new name", + names.Contains(Name) && !names.Contains("Shubert_38")); + // The name is written twice: as the root's name and as the root Frame's actor link. + byte[] frameBytes = File.ReadAllBytes(manifest.GetFiles("FrameResource")[0]); + Check($"{label}: nothing in the frames names the old car", + Occurrences(frameBytes, BitConverter.GetBytes(Fnv64.Hash("Shubert_38"))) == 0 + && Occurrences(frameBytes, BitConverter.GetBytes(Fnv64.Hash(Name))) == 2); + var nameTable = new FrameNameTable(manifest.GetFiles("FrameNameTable")[0]); + Check($"{label}: so does the name table", nameTable.Names.Values.Contains(Name), + string.Join(", ", nameTable.Names.Values)); + PrefabFile prefab = PrefabFile.Load(manifest.GetFiles("PREFAB")[0]); + Check($"{label}: the prefab entry follows it", + prefab.Contains(Fnv64.Hash(Name)) && !prefab.Contains(Fnv64.Hash("Shubert_38"))); + EntityDataStorageFile storage = EntityDataStorageFile.Load(manifest.GetFiles("EntityDataStorage")[0]); + Check($"{label}: the entity data is filed under the name in lower case", + storage.Hash == Fnv64.Hash(Name.ToLowerInvariant()) && storage.TableCount > 0); + Check($"{label}: packs", Packs(to)); + } + Check("the toolkit reads the clone as a car", Car.ReadFrom(cars[0].To) != null); + Check("tables.sds packs", Packs(tables)); + Check("ingame.sds packs", Packs(ingame)); + // Packing recompiles every XML resource; what it compiles has to decompile back to the same text, + // or a Build of the table archives quietly rewrites configs nobody touched. + foreach (string folder in new[] { tables, ingame }) + { + string back = Path.Combine(scratch, Path.GetFileName(folder) + "_back"); + SdsArchive archive = SdsArchive.Pack(folder, GameProfile.MafiaII); + using (var stream = new MemoryStream()) + { + archive.Save(stream, new SdsWriteOptions()); + string packed = Path.Combine(scratch, Path.GetFileName(folder) + ".sds"); + File.WriteAllBytes(packed, stream.ToArray()); + SdsArchive.Open(packed).Extract(back); + } + var changed = new List(); + int xmls = 0; + foreach (string file in Directory.EnumerateFiles(folder, "*.xml", SearchOption.AllDirectories)) + { + string rel = Path.GetRelativePath(folder, file); + if (rel == "SDSContent.xml") continue; + xmls++; + string again = Path.Combine(back, rel); + if (!File.Exists(again) || File.ReadAllText(again) != File.ReadAllText(file)) changed.Add(rel); + } + Check($"{Path.GetFileName(folder)}: every XML survives a pack unchanged", xmls > 0 && changed.Count == 0, + $"{changed.Count} of {xmls} differ: {string.Join(", ", changed.Take(5))}"); + } + // A Build prunes manifest entries whose file is gone; an XML resource sits on disk as name + ".xml", + // and every one of them used to be pruned that way. + foreach (string folder in new[] { tables, ingame, cars[0].To }) + { + List pruned = SdsWriter.PruneMissingEntries(folder); + Check($"{Path.GetFileName(folder)}: a Build drops no manifest entry", pruned.Count == 0, + string.Join(", ", pruned.Take(5))); + } + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + try { if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); } + catch (IOException) { /* scratch left behind */ } + sb.Insert(0, $"CAR CLONE PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static bool Packs(string folder) + { + SdsArchive archive = SdsArchive.Pack(folder, GameProfile.MafiaII); + using var output = new MemoryStream(); + archive.Save(output, new SdsWriteOptions()); + return output.Length > 0; + } + + private static string Copy(string from, string to) + { + foreach (string file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(to, Path.GetRelativePath(from, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + return to; + } + + private static int Occurrences(byte[] haystack, byte[] needle) + { + int count = 0; + for (int i = 0; i + needle.Length <= haystack.Length; i++) + { + if (haystack.AsSpan(i, needle.Length).SequenceEqual(needle)) count++; + } + return count; + } + + private static int IndexOf(byte[] haystack, byte[] needle) + { + for (int i = 0; i + needle.Length <= haystack.Length; i++) + { + if (haystack.AsSpan(i, needle.Length).SequenceEqual(needle)) return i; + } + return -1; + } +} diff --git a/src/Illusion/Diagnostics/Probes/McpProbes.cs b/src/Illusion/Diagnostics/Probes/McpProbes.cs index 0e32cc1..b92a9d8 100644 --- a/src/Illusion/Diagnostics/Probes/McpProbes.cs +++ b/src/Illusion/Diagnostics/Probes/McpProbes.cs @@ -65,6 +65,7 @@ internal static class McpProbes "resource_status", "car_tuning", "car_tuning_set", + "car_clone", }; /// Records one assertion. A delegate rather than an Action so the optional diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 1933112..5dcd76e 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -788,6 +788,23 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) return null; } + public string? CloneCar(string source, string name, bool traffic, out CarCloneInfo? result) + { + result = null; + if (EnsureEnvironment() is { } notReady) return notReady; + try + { + if (Assets.Cars.CarCloner.Clone(source, name, traffic, out string? refusal) is not { } outcome) return refusal; + result = new CarCloneInfo(outcome.Name, outcome.VehicleId, outcome.TrafficRows, + [.. outcome.Packed.Select(p => new PackedArchive(p.Archive, p.Backup))], outcome.Notes); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return "could not write the archives (is the game running?): " + ex.Message; + } + } + public string? Undo() { if (TargetHost is not { } host) return TargetNotOpen; From 5c637e100d54a2ad59644e7aaf087b48d0add7d9 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:45:39 +0300 Subject: [PATCH 029/102] feat(mcp): archive_build packs one archive's working copy For an edit made in the working copy itself - a script, a table file - that no editor session tracks: packs the extracted folder back into its .sds through SdsWriter.PackSds, keeping the usual timestamped backup. editor_build stays the way to pack what the editors changed. Used on pc\dlcs\cnt_jimmys_vendetta\sds_ru\missionscript\ fr_game_director.sds after a script edit: 8 resources before and after, only the script pack differs (+301 bytes, the edit). --probe-mcp: 66/0. --- src/Illusion.Mcp/IEditorSession.cs | 4 ++++ src/Illusion.Mcp/Tools/EditorTools.cs | 19 ++++++++++++++++ src/Illusion/Diagnostics/Probes/McpProbes.cs | 1 + src/Illusion/Mcp/AppEditorSession.cs | 24 ++++++++++++++++++++ 4 files changed, 48 insertions(+) diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index 52064d6..f9b8c7e 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -139,6 +139,10 @@ public interface IEditorSession /// success. string? CloneCar(string source, string name, bool traffic, out CarCloneInfo? result); + /// Packs one archive's working copy back into its .sds, keeping a backup of what it replaces — for an + /// edit made in the working copy itself (a script, a table) that no editor session tracks. Null on success. + string? BuildArchive(string archive, out PackedArchive? result); + string? Undo(); string? Redo(); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 50b596e..caf6811 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -183,6 +183,25 @@ public static async Task CarClone( } } + [McpServerTool(Name = "archive_build")] + [Description("Pack ONE archive's working copy (the extracted folder under \\resources) back into its .sds, keeping a timestamped backup of the archive it replaces. For an edit made directly in the working copy — a script, a table file — that no editor session tracks; editor_build remains the way to pack what the editors changed. This OVERWRITES a game file: the game must not be running.")] + public static async Task ArchiveBuild( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("Full path of the .sds archive in the game folder (pc\\sds\\… or pc\\dlcs\\…).")] string archive) + { + try + { + PackedArchive? result = null; + string? refused = await ui.RunAsync(() => editor.BuildArchive(archive, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, packed = result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + [McpServerTool(Name = "editor_list_areas")] [Description("Names of the areas (districts and interiors) the map editor can load. Empty until the editor is open — editor_open_area opens it.")] public static async Task ListAreas(IEditorSession editor, IUiThreadMarshal ui) diff --git a/src/Illusion/Diagnostics/Probes/McpProbes.cs b/src/Illusion/Diagnostics/Probes/McpProbes.cs index b92a9d8..7b5d489 100644 --- a/src/Illusion/Diagnostics/Probes/McpProbes.cs +++ b/src/Illusion/Diagnostics/Probes/McpProbes.cs @@ -66,6 +66,7 @@ internal static class McpProbes "car_tuning", "car_tuning_set", "car_clone", + "archive_build", }; /// Records one assertion. A delegate rather than an Action so the optional diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 5dcd76e..48a9211 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -788,6 +788,30 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) return null; } + public string? BuildArchive(string archive, out PackedArchive? result) + { + result = null; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(archive) || !Path.IsPathRooted(archive)) return "give the archive's full path"; + var sds = new FileInfo(archive); + if (!sds.Exists || !sds.Extension.Equals(".sds", StringComparison.OrdinalIgnoreCase)) return $"no such archive: {archive}"; + string extracted = Assets.MafiaEnvironment.ExtractedDir(sds); + if (!File.Exists(Path.Combine(extracted, "SDSContent.xml"))) + { + return $"{sds.Name} has no working copy at {extracted} — nothing to pack"; + } + try + { + Assets.Sds.SdsWriter.PackResult packed = Assets.Sds.SdsWriter.PackSds(sds, createBackup: true); + result = new PackedArchive(packed.Archive, packed.Backup); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Formats.FileFormatException) + { + return "could not pack the archive (is the game running?): " + ex.Message; + } + } + public string? CloneCar(string source, string name, bool traffic, out CarCloneInfo? result) { result = null; From 549a22386f140fe443a55e318b7462cad98ce1c0 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:07:02 +0300 Subject: [PATCH 030/102] fix: a rebuilt name table keeps the order it was loaded in A cloned car sat in the garage with its name and no model. Two causes, both found against the game: - The name table was rebuilt in frame-index order. The order is authored, not derivable: every one of the 103 cars the game ships lists its root frame first, and only 34 happen to be in index order. The clone's table led with an effects frame. Each frame now carries the position the loaded table gave it (FrameNameTableOrder, set where the flags are linked), and BuildDataFromResource emits in that order, frames the table never listed after them by index. This is every rebuild, the Resource Editor's car save included. - The game finds archives through its cached file list (%LOCALAPPDATA%\2K Games\Mafia II\Data\vfs.bin) and does not pick up a NEW archive on its own. GameFileIndex.Reset removes the cache, the way Steam's install script does; car_clone calls it and says so. --probe-car-clone: 36/0 - the clone lists its root first; rebuilding the name table of each of 103 shipped cars reproduces it byte for byte (five tables that list a frame twice or name a frame the resource does not hold are left out). --probe-nametable: 1331/1331 semantic, 960 byte-identical. --probe-object-transplant 38/0, --probe-mcp 66/0. Whether the clone now shows in game is not yet confirmed. --- src/Illusion.Assets/Cars/CarCloner.cs | 7 +++- src/Illusion.Assets/Sds/GameFileIndex.cs | 35 +++++++++++++++++++ src/Illusion.Formats/Frames/ExtractedSds.cs | 4 ++- src/Illusion.Formats/Frames/FrameNameTable.cs | 16 +++++++-- .../Frames/ObjectTypes/FrameObjectBase.cs | 8 +++++ .../Diagnostics/Probes/CarCloneProbes.cs | 32 +++++++++++++++++ 6 files changed, 98 insertions(+), 4 deletions(-) create mode 100644 src/Illusion.Assets/Sds/GameFileIndex.cs diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs index 00f2454..b5dc09a 100644 --- a/src/Illusion.Assets/Cars/CarCloner.cs +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -120,7 +120,12 @@ public static partial class CarCloner SdsWriter.PackResult made = SdsWriter.PackSds(table, createBackup: true, when); packed.Add((made.Archive, made.Backup)); } - return new CarCloneOutcome(name, result.VehicleId, packed, result.TrafficRows, result.Notes); + // The game finds archives through its cached file list, and a new one is not in it. + var notes = new List(result.Notes); + notes.Add(GameFileIndex.Reset() + ? "the game's file list (vfs.bin) was reset — the next start rebuilds it with the new archives" + : $"the game's file list was not reset — remove {GameFileIndex.Path} before starting the game, or it will not find the new archives"); + return new CarCloneOutcome(name, result.VehicleId, packed, result.TrafficRows, notes); } /// diff --git a/src/Illusion.Assets/Sds/GameFileIndex.cs b/src/Illusion.Assets/Sds/GameFileIndex.cs new file mode 100644 index 0000000..d09b0e1 --- /dev/null +++ b/src/Illusion.Assets/Sds/GameFileIndex.cs @@ -0,0 +1,35 @@ +namespace Illusion.Assets.Sds; + +/// +/// The game's cached list of its own files: %LOCALAPPDATA%\2K Games\Mafia II\Data\vfs.bin. +/// +/// +/// The game finds an archive through this list, not through the folder. It refreshes the list only in part +/// when it starts, and a NEW archive dropped into pc\sds is not picked up: a cloned car was in the +/// vehicle table and in the garage, and still had no model, until the cache was removed and rebuilt in full. +/// Steam's own install script removes it for the same reason (RemoveSaves.exe /vfsonly). An archive +/// that only changed — the same name as before — needs none of this. +/// +/// +public static class GameFileIndex +{ + /// Where the cache lives for the current user. + public static string Path => System.IO.Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "2K Games", "Mafia II", "Data", "vfs.bin"); + + /// Removes the cache so the next start of the game builds it from the files that are there. + /// True when a cache was removed; false when there was none, or it is in use (the game is running). + public static bool Reset() + { + try + { + if (!File.Exists(Path)) return false; + File.Delete(Path); + return true; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } +} diff --git a/src/Illusion.Formats/Frames/ExtractedSds.cs b/src/Illusion.Formats/Frames/ExtractedSds.cs index 869ba1d..bd2a209 100644 --- a/src/Illusion.Formats/Frames/ExtractedSds.cs +++ b/src/Illusion.Formats/Frames/ExtractedSds.cs @@ -86,8 +86,9 @@ private static void LinkNameTableFlags(FrameResource? frameResource, FrameNameTa objects.Add(value as FrameObjectBase); } - foreach (var data in nameTable.FrameData) + for (int order = 0; order < nameTable.FrameData.Length; order++) { + FrameNameTable.Data data = nameTable.FrameData[order]; if (data.FrameIndex < 0 || data.FrameIndex >= objects.Count) { continue; @@ -99,6 +100,7 @@ private static void LinkNameTableFlags(FrameResource? frameResource, FrameNameTa } obj.IsOnFrameTable = true; obj.FrameNameTableFlags = data.Flags; + obj.FrameNameTableOrder = order; } } } diff --git a/src/Illusion.Formats/Frames/FrameNameTable.cs b/src/Illusion.Formats/Frames/FrameNameTable.cs index 3cc9162..ff0917b 100644 --- a/src/Illusion.Formats/Frames/FrameNameTable.cs +++ b/src/Illusion.Formats/Frames/FrameNameTable.cs @@ -70,10 +70,22 @@ public void BuildDataFromResource(FrameResource resource) var objects = new List(resource.FrameObjects.Count); foreach (object value in resource.FrameObjects.Values) objects.Add(value as FrameObjectBase); + // Entries go out in the order the loaded table had them (objects it never listed follow, by index): + // the order is authored, not derivable — and the game takes a car's first entry for its root. + var listed = new List(objects.Count); for (int i = 0; i < objects.Count; i++) { - FrameObjectBase? fBase = objects[i]; - if (fBase == null || !fBase.IsOnFrameTable) continue; + if (objects[i] is { IsOnFrameTable: true }) listed.Add(i); + } + listed.Sort((a, b) => + { + int byOrder = objects[a]!.FrameNameTableOrder.CompareTo(objects[b]!.FrameNameTableOrder); + return byOrder != 0 ? byOrder : a.CompareTo(b); + }); + + foreach (int i in listed) + { + FrameObjectBase fBase = objects[i]!; Data data = new Data(); data.Flags = fBase.FrameNameTableFlags; diff --git a/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectBase.cs b/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectBase.cs index 6f1d602..4401012 100644 --- a/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectBase.cs +++ b/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectBase.cs @@ -121,6 +121,14 @@ public bool IsOnFrameTable get { return isOnTable; } set { isOnTable = value; } } + + /// + /// Where the loaded name table listed this object. A rebuilt table keeps that order, because the game reads + /// meaning into it: every car the game ships lists its root frame first, whatever the root's index is — and + /// a car whose table led with another frame did not appear in game. An object the table never listed sorts + /// after the ones it did. + /// + public int FrameNameTableOrder { get; set; } = int.MaxValue; public string Type { get { return GetType().ToString(); } diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index d118f80..0322a53 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -129,6 +129,10 @@ void Check(string name, bool ok, string detail = "") var nameTable = new FrameNameTable(manifest.GetFiles("FrameNameTable")[0]); Check($"{label}: so does the name table", nameTable.Names.Values.Contains(Name), string.Join(", ", nameTable.Names.Values)); + // Every shipped car lists its root first; a clone that led with another frame had no model in game. + Check($"{label}: the name table still lists the root first", + nameTable.FrameData is { Length: > 0 } entries && entries[0].Name == Name, + string.Join(", ", (nameTable.FrameData ?? []).Select(e => e.Name))); PrefabFile prefab = PrefabFile.Load(manifest.GetFiles("PREFAB")[0]); Check($"{label}: the prefab entry follows it", prefab.Contains(Fnv64.Hash(Name)) && !prefab.Contains(Fnv64.Hash("Shubert_38"))); @@ -138,6 +142,34 @@ void Check(string name, bool ok, string detail = "") Check($"{label}: packs", Packs(to)); } Check("the toolkit reads the clone as a car", Car.ReadFrom(cars[0].To) != null); + + // A rebuilt name table has to be the table the archive shipped with — order included. + int cars_ = 0, same = 0; + var differing = new List(); + foreach (string archive in Directory.EnumerateFiles(Path.Combine(sds, "cars"), "*.sds")) + { + string folder = MafiaEnvironment.ExtractedDir(new FileInfo(archive)); + if (!File.Exists(Path.Combine(folder, "SDSContent.xml"))) continue; + ExtractedSds loaded = ExtractedSds.Load(folder); + IReadOnlyList tableFiles = loaded.Manifest.GetFiles("FrameNameTable"); + if (loaded.FrameResource == null || tableFiles.Count == 0) continue; + // Five shipped tables list a frame twice or name a frame the resource does not hold; a table + // built from the frames has one entry per frame, so those are out of this comparison. + FrameNameTable.Data[] shipped = loaded.FrameNameTable?.FrameData ?? []; + if (shipped.Any(e => e.FrameIndex < 0) || shipped.Select(e => e.FrameIndex).Distinct().Count() != shipped.Length) + { + continue; + } + var rebuilt = new FrameNameTable(); + rebuilt.BuildDataFromResource(loaded.FrameResource); + using var ms = new MemoryStream(); + using (var writer = new BinaryWriter(ms, Encoding.UTF8, leaveOpen: true)) rebuilt.WriteToFile(writer); + cars_++; + if (ms.ToArray().AsSpan().SequenceEqual(File.ReadAllBytes(tableFiles[0]))) same++; + else differing.Add(Path.GetFileNameWithoutExtension(archive)); + } + Check("rebuilding a car's name table reproduces it byte for byte", cars_ > 50 && same == cars_, + $"{same} of {cars_}; differ: {string.Join(", ", differing.Take(8))}"); Check("tables.sds packs", Packs(tables)); Check("ingame.sds packs", Packs(ingame)); // Packing recompiles every XML resource; what it compiles has to decompile back to the same text, From 04c0443eb3437a10351a9769ba0124b08f9eb9cc Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:24:21 +0300 Subject: [PATCH 031/102] feat(cars): a clone gets buffers and a title of its own - Buffers. The game keeps vertex and index buffers by name across every archive it has loaded, so a clone that kept the source's names would draw whichever shape streamed first once its model is edited - and so would the source car. Cloning now renames every buffer the geometry draws from, in the geometry blocks and in the pool files ("Shubert_38.Root.L0.VB0" -> ".Root.L0.VB0"; a name that does not start with the model's gets the new one in front). - Title. car_clone takes an optional title: one new string in the text table of every installed language (sds_\text\text_default.sds, tables\TextDatabase.dat - UTF-8 "KEY:TEXT" lines, id 60000017 is key 00_60_00_0017), under an id from 60001000 + vehicle id that nothing uses, and the clone's vehicles.tbl row points at it. Without a title the clone shares the source car's name, as before. GameText reads and adds lines; the text archive is built with the others. --probe-car-clone: 48/0 (the clone's buffers share no name with the source's, every level of detail resolves, the bytes are the source's in the source's order; the title is one added line and nothing else, the text archive packs, its XML survives the pack, nothing is pruned). In game: the first clone showed in the garage with its model after the name-table fix; this build of it is confirmed to start, not yet looked at in the garage. --- src/Illusion.Assets/Cars/CarCloneFolders.cs | 8 +- src/Illusion.Assets/Cars/CarCloneOutcome.cs | 1 + src/Illusion.Assets/Cars/CarCloneResult.cs | 5 +- src/Illusion.Assets/Cars/CarCloner.cs | 147 +++++++++++++++--- src/Illusion.Assets/Text/GameText.cs | 78 ++++++++++ src/Illusion.Mcp/EditorModels.cs | 5 +- src/Illusion.Mcp/IEditorSession.cs | 2 +- src/Illusion.Mcp/Tools/EditorTools.cs | 7 +- .../Diagnostics/Probes/CarCloneProbes.cs | 59 ++++++- src/Illusion/Mcp/AppEditorSession.cs | 6 +- 10 files changed, 276 insertions(+), 42 deletions(-) create mode 100644 src/Illusion.Assets/Text/GameText.cs diff --git a/src/Illusion.Assets/Cars/CarCloneFolders.cs b/src/Illusion.Assets/Cars/CarCloneFolders.cs index 29400de..0d6e977 100644 --- a/src/Illusion.Assets/Cars/CarCloneFolders.cs +++ b/src/Illusion.Assets/Cars/CarCloneFolders.cs @@ -1,5 +1,7 @@ namespace Illusion.Assets.Cars; -/// The working copies a car clone writes: tables.sds and ingame.sds, and each car archive (the -/// car and its winter twin) with the folder its copy goes to. -public sealed record CarCloneFolders(string Tables, string Ingame, IReadOnlyList<(string From, string To)> Cars); +/// The working copies a car clone writes: tables.sds and ingame.sds, each car archive (the car and +/// its winter twin) with the folder its copy goes to, and the text archive of every installed language +/// (text_default.sds) for the clone's own name. +public sealed record CarCloneFolders( + string Tables, string Ingame, IReadOnlyList<(string From, string To)> Cars, IReadOnlyList Text); diff --git a/src/Illusion.Assets/Cars/CarCloneOutcome.cs b/src/Illusion.Assets/Cars/CarCloneOutcome.cs index 5c7d863..97cc4c8 100644 --- a/src/Illusion.Assets/Cars/CarCloneOutcome.cs +++ b/src/Illusion.Assets/Cars/CarCloneOutcome.cs @@ -10,4 +10,5 @@ public sealed record CarCloneOutcome( int VehicleId, IReadOnlyList<(string Archive, string? Backup)> Packed, int TrafficRows, + int? TextId, IReadOnlyList Notes); diff --git a/src/Illusion.Assets/Cars/CarCloneResult.cs b/src/Illusion.Assets/Cars/CarCloneResult.cs index 0bff6d8..6889b41 100644 --- a/src/Illusion.Assets/Cars/CarCloneResult.cs +++ b/src/Illusion.Assets/Cars/CarCloneResult.cs @@ -1,5 +1,6 @@ namespace Illusion.Assets.Cars; /// What a clone wrote into the working copies: the new vehicle id, the traffic rows that now pick it, -/// and what was left out. -public sealed record CarCloneResult(int VehicleId, int TrafficRows, IReadOnlyList Notes); +/// the id of the text its name is filed under (null when it shares the source car's name), and what was left +/// out. +public sealed record CarCloneResult(int VehicleId, int TrafficRows, int? TextId, IReadOnlyList Notes); diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs index b5dc09a..3aac79d 100644 --- a/src/Illusion.Assets/Cars/CarCloner.cs +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -1,9 +1,12 @@ using System.Text.RegularExpressions; using Illusion.Assets.Sds; +using Illusion.Assets.Text; using Illusion.Formats.Archive; using Illusion.Formats.EntityData; using Illusion.Formats.Frames; using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Geometry; using Illusion.Formats.Hashing; using Illusion.Formats.Prefab; using Illusion.Formats.ResourceFormats; @@ -20,8 +23,13 @@ namespace Illusion.Assets.Cars; /// frame carries the model name, the PREFAB entry (seats, doors, wheels, deformation) is keyed by FNV64 of that /// name, and the entity-data storage (the tuning tables) by FNV64 of the name in lower case. Paint combinations /// (PaintCombinations.tbl) and the cover points around the car (AiProps/<name>.xml) live in -/// ingame.sds under the id and the name; traffic (CARM*.tbl) picks cars by id. Everything else inside the -/// archive — buffers, textures, sounds — is addressed archive-locally or shared, and stays as it is. +/// ingame.sds under the id and the name; traffic (CARM*.tbl) picks cars by id. +/// +/// +/// The vertex and index buffers get names of their own as well. The game keeps buffers by name across every +/// archive it has loaded, so a clone that kept the source's names would draw whichever of the two shapes +/// streamed first the moment its model is edited — and so would the car it was cloned from. Textures, materials +/// and sounds stay shared with the source: the clone looks and sounds like it until those are changed too. /// /// public static partial class CarCloner @@ -31,6 +39,11 @@ public static partial class CarCloner private const int VehicleIdColumn = 0; private const int VehicleNameColumn = 2; + private const int VehicleTextColumn = 3; + + /// Where the ids of clone names start: clear of the car names the game and its DLCs ship + /// (60000000–60000059). + private const int FirstTitleId = 60001000; private const int PaintIdColumn = 0; private const int PaintNameColumn = 1; private const int TrafficCountColumn = 1; @@ -52,9 +65,10 @@ public static partial class CarCloner /// Clones a car of the game the toolkit has open, then builds the new archives and the two table archives /// (keeping a backup of each that existed). is the car's archive or model name /// (shubert_38). With , every traffic row that can pick the source car can - /// pick the clone as well. + /// pick the clone as well. With , the clone gets a name of its own in the text of + /// every installed language — without it, it is called what the source car is called. /// - public static CarCloneOutcome? Clone(string source, string name, bool traffic, out string? refusal) + public static CarCloneOutcome? Clone(string source, string name, bool traffic, string? title, out string? refusal) { ArgumentNullException.ThrowIfNull(source); ArgumentNullException.ThrowIfNull(name); @@ -97,15 +111,21 @@ public static partial class CarCloner return null; } + List textSds = title == null + ? [] + : [.. Directory.GetDirectories(MafiaEnvironment.PcFolder, "sds_*") + .Select(d => new FileInfo(Path.Combine(d, "text", "text_default.sds"))).Where(f => f.Exists)]; + var folders = new CarCloneFolders( SdsMeshLoader.EnsureExtracted(tablesSds), SdsMeshLoader.EnsureExtracted(ingameSds), - [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.ExtractedDir(c.To)))]); + [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.ExtractedDir(c.To)))], + [.. textSds.Select(SdsMeshLoader.EnsureExtracted)]); // A folder left behind by an attempt that never produced its archive is nobody's working copy. foreach ((string _, string to) in folders.Cars) SdsWriter.DeleteExtracted(to); - CarCloneResult? result = CloneExtracted(folders, stem, name, traffic, out refusal); + CarCloneResult? result = CloneExtracted(folders, stem, name, traffic, title, out refusal); if (result == null) return null; DateTime when = DateTime.Now; @@ -115,7 +135,8 @@ public static partial class CarCloner SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: false, when); packed.Add((made.Archive, made.Backup)); } - foreach (FileInfo table in new[] { tablesSds, ingameSds }) + IEnumerable changed = result.TextId == null ? [tablesSds, ingameSds] : [tablesSds, ingameSds, .. textSds]; + foreach (FileInfo table in changed) { SdsWriter.PackResult made = SdsWriter.PackSds(table, createBackup: true, when); packed.Add((made.Archive, made.Backup)); @@ -125,7 +146,7 @@ public static partial class CarCloner notes.Add(GameFileIndex.Reset() ? "the game's file list (vfs.bin) was reset — the next start rebuilds it with the new archives" : $"the game's file list was not reset — remove {GameFileIndex.Path} before starting the game, or it will not find the new archives"); - return new CarCloneOutcome(name, result.VehicleId, packed, result.TrafficRows, notes); + return new CarCloneOutcome(name, result.VehicleId, packed, result.TrafficRows, result.TextId, notes); } /// @@ -135,11 +156,17 @@ public static partial class CarCloner /// car is not in the vehicle table or the name is already taken. /// public static CarCloneResult? CloneExtracted( - CarCloneFolders folders, string source, string name, bool traffic, out string? refusal) + CarCloneFolders folders, string source, string name, bool traffic, string? title, out string? refusal) { ArgumentNullException.ThrowIfNull(folders); refusal = Refuse(name); if (refusal != null) return null; + title = title?.Trim(); + if (title != null && (title.Length == 0 || title.Length > 48 || title.Any(char.IsControl) || title.Contains(':'))) + { + refusal = "a car's title is one line of at most 48 characters, without ':'"; + return null; + } string vehiclesPath = Path.Combine(folders.Tables, "tables", "vehicles.tbl"); if (!File.Exists(vehiclesPath)) @@ -190,6 +217,21 @@ public static partial class CarCloner vehicles.SetCell(added, VehicleIdColumn, id); vehicles.SetCell(added, VehicleNameColumn, name); + // A name of its own: one new string, under the same id in every language's text table. + int? textId = null; + List textTables = [.. folders.Text.Select(t => Path.Combine(t, "tables", "TextDatabase.dat")).Where(File.Exists)]; + if (title != null && textTables.Count > 0) + { + int free = FirstTitleId + id; + while (textTables.Any(t => GameText.Find(t, free) != null)) free++; + textId = free; + vehicles.SetCell(added, VehicleTextColumn, free); + } + else if (title != null) + { + notes.Add("no text archive (sds_\\text\\text_default.sds) — the clone keeps the source car's name"); + } + string paintPath = Path.Combine(folders.Ingame, "tables", "PaintCombinations.tbl"); GameTable? paint = File.Exists(paintPath) ? GameTable.Load(paintPath) : null; int paintRow = paint?.FindRow(PaintNameColumn, model) ?? -1; @@ -219,6 +261,10 @@ public static partial class CarCloner } AtomicFile.WriteAllBytes(vehiclesPath, vehicles.ToBytes()); + if (textId is { } titleId) + { + foreach (string table in textTables) GameText.Add(table, titleId, title!); + } if (paint != null && paintRow >= 0) AtomicFile.WriteAllBytes(paintPath, paint.ToBytes()); foreach ((string path, GameTable table) in trafficTables) AtomicFile.WriteAllBytes(path, table.ToBytes()); if (!CopyCoverPoints(folders.Ingame, model, name)) @@ -227,7 +273,7 @@ public static partial class CarCloner } refusal = null; - return new CarCloneResult(id, trafficRows, notes); + return new CarCloneResult(id, trafficRows, textId, notes); } // Every traffic row that can pick the source car can pick the clone too: a row is a weight, a count and @@ -285,6 +331,9 @@ private static List RenameInside(string folder, string model, string nam { FrameObjectBase? root = frame.FrameObjects.Values.OfType() .FirstOrDefault(f => string.Equals(f.Name.String, model, StringComparison.OrdinalIgnoreCase)); + int buffers = GiveOwnBuffers(manifest, frame, model, name); + if (buffers == 0) notes.Add($"{label}: no buffer was renamed — the clone draws from the source car's buffers"); + if (root != null) { root.Name = new HashName(name); @@ -294,21 +343,22 @@ private static List RenameInside(string folder, string model, string nam { linked.ActorHash = new HashName(name); } - AtomicFile.WriteAllBytes(frames[0], frame.WriteToStream()); - IReadOnlyList tables = manifest.GetFiles("FrameNameTable"); - if (tables.Count > 0) - { - var table = new FrameNameTable(); - table.BuildDataFromResource(frame); - using var ms = new MemoryStream(); - using (var writer = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) table.WriteToFile(writer); - AtomicFile.WriteAllBytes(tables[0], ms.ToArray()); - } } else { notes.Add($"{label}: no frame is named {model} — the root keeps its name"); } + + AtomicFile.WriteAllBytes(frames[0], frame.WriteToStream()); + IReadOnlyList tables = manifest.GetFiles("FrameNameTable"); + if (tables.Count > 0) + { + var table = new FrameNameTable(); + table.BuildDataFromResource(frame); + using var ms = new MemoryStream(); + using (var writer = new BinaryWriter(ms, System.Text.Encoding.UTF8, leaveOpen: true)) table.WriteToFile(writer); + AtomicFile.WriteAllBytes(tables[0], ms.ToArray()); + } } foreach (string path in manifest.GetFiles("PREFAB")) @@ -332,6 +382,63 @@ private static List RenameInside(string folder, string model, string nam return notes; } + // Renames every buffer the geometry draws from — in the geometry blocks and in the pool files, where a + // buffer keeps its place. "Shubert_38.Root.L0.VB0" becomes ".Root.L0.VB0"; a name that does not start + // with the model's gets the new name in front. + private static int GiveOwnBuffers(SdsManifest manifest, FrameResource frame, string model, string name) + { + var renamed = new Dictionary(); + HashName Renamed(HashName old) + { + if (!renamed.TryGetValue(old.Hash, out HashName? fresh)) + { + string text = old.String ?? ""; + string given = text.StartsWith(model, StringComparison.OrdinalIgnoreCase) + ? name + text[model.Length..] + : $"{name}.{(text.Length > 0 ? text : old.Hash.ToString("x16"))}"; + renamed[old.Hash] = fresh = new HashName(given); + } + return fresh; + } + + foreach (FrameGeometry geometry in frame.FrameGeometries.Values) + { + foreach (FrameLOD lod in geometry.LOD ?? []) + { + lod.VertexBufferRef = Renamed(lod.VertexBufferRef); + lod.IndexBufferRef = Renamed(lod.IndexBufferRef); + } + } + + foreach (string path in manifest.GetFiles("VertexBufferPool")) + { + var pool = new VertexBufferPool(new MemoryStream(File.ReadAllBytes(path), writable: false)); + var rewritten = new VertexBufferPool(); + foreach (VertexBuffer buffer in pool.Buffers.Values) + { + if (renamed.TryGetValue(buffer.Hash, out HashName? fresh)) buffer.Hash = fresh.Hash; + rewritten.Buffers[buffer.Hash] = buffer; + } + using var stream = new MemoryStream(); + rewritten.WriteToFile(stream); + AtomicFile.WriteAllBytes(path, stream.ToArray()); + } + foreach (string path in manifest.GetFiles("IndexBufferPool")) + { + var pool = new IndexBufferPool(new MemoryStream(File.ReadAllBytes(path), writable: false)); + var rewritten = new IndexBufferPool(); + foreach (IndexBuffer buffer in pool.Buffers.Values) + { + if (renamed.TryGetValue(buffer.Hash, out HashName? fresh)) buffer.Hash = fresh.Hash; + rewritten.Buffers[buffer.Hash] = buffer; + } + using var stream = new MemoryStream(); + rewritten.WriteToFile(stream); + AtomicFile.WriteAllBytes(path, stream.ToArray()); + } + return renamed.Count; + } + private static void CopyTree(string from, string to) { foreach (string file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories)) diff --git a/src/Illusion.Assets/Text/GameText.cs b/src/Illusion.Assets/Text/GameText.cs new file mode 100644 index 0000000..69f5bd8 --- /dev/null +++ b/src/Illusion.Assets/Text/GameText.cs @@ -0,0 +1,78 @@ +using System.Globalization; +using System.Text; + +namespace Illusion.Assets.Text; + +/// +/// The game's text table as an archive's working copy holds it (tables\TextDatabase.dat of +/// sds_<language>\text\text_default.sds): UTF-8 with a byte-order mark, one CRLF-terminated +/// KEY:TEXT line per string. A string's numeric id is its key without the separators — id 60000017 is the +/// line 00_60_00_0017:Shubert 38, which is how vehicles.tbl names a car. +/// +public static class GameText +{ + private static readonly UTF8Encoding Utf8 = new(encoderShouldEmitUTF8Identifier: false); + private static readonly byte[] Bom = [0xEF, 0xBB, 0xBF]; + + /// The key a numeric id is filed under: ten digits in groups of 2, 2, 2 and 4. + public static string Key(int id) + { + string digits = id.ToString("D10", CultureInfo.InvariantCulture); + return $"{digits[..2]}_{digits[2..4]}_{digits[4..6]}_{digits[6..]}"; + } + + /// The text filed under , or null when the table has no such line. + public static string? Find(string path, int id) + { + string prefix = Key(id) + ":"; + foreach (string line in Lines(path, out _)) + { + if (line.StartsWith(prefix, StringComparison.Ordinal)) return line[prefix.Length..]; + } + return null; + } + + /// + /// Adds one string. The line goes after the last line of its own block (the keys sharing everything but + /// the final group) that sorts before it, so the table keeps the order it ships in; with no such block it + /// goes at the end. + /// + /// False when the id is already taken — the table is left as it was. + public static bool Add(string path, int id, string text) + { + ArgumentNullException.ThrowIfNull(text); + if (text.Contains('\r') || text.Contains('\n')) throw new ArgumentException("a string is one line", nameof(text)); + + List lines = Lines(path, out bool hadBom); + string key = Key(id); + if (lines.Any(l => l.StartsWith(key + ":", StringComparison.Ordinal))) return false; + + string block = key[..9]; + int at = -1; + for (int i = 0; i < lines.Count; i++) + { + if (lines[i].Length > key.Length && lines[i].StartsWith(block, StringComparison.Ordinal) + && string.CompareOrdinal(lines[i][..key.Length], key) < 0) + { + at = i; + } + } + lines.Insert(at < 0 ? lines.Count : at + 1, $"{key}:{text}"); + + var bytes = new List(hadBom ? Bom : []); + foreach (string line in lines) bytes.AddRange(Utf8.GetBytes(line + "\r\n")); + AtomicFile.WriteAllBytes(path, [.. bytes]); + return true; + } + + // The table's lines without their terminators. A final CRLF does not make an empty last line. + private static List Lines(string path, out bool hadBom) + { + byte[] bytes = File.ReadAllBytes(path); + hadBom = bytes.AsSpan().StartsWith(Bom); + string text = Utf8.GetString(bytes, hadBom ? Bom.Length : 0, bytes.Length - (hadBom ? Bom.Length : 0)); + var lines = new List(text.Split("\r\n")); + if (lines.Count > 0 && lines[^1].Length == 0) lines.RemoveAt(lines.Count - 1); + return lines; + } +} diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index e07f355..3ab3758 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -33,8 +33,9 @@ public sealed record ResourceStatus( public sealed record LibraryItem(string Name, string Path, string Kind, long Size, bool Extracted); /// A car cloned under a new name: the vehicle id the tables gave it, how many traffic rows pick it, -/// each archive written with the backup taken of it (null for a new one), and what was left out. -public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, +/// the id of the text holding its own title (null when it shares the source car's), each archive written with +/// the backup taken of it (null for a new one), and what was left out. +public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, int? TextId, IReadOnlyList Packed, IReadOnlyList Notes); /// One archive a car clone wrote, and the backup of what it replaced. diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index f9b8c7e..9c7b061 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -137,7 +137,7 @@ public interface IEditorSession /// Clones a car under a new model name and registers it (vehicle table, paint, cover points and, /// with , the traffic rows of the source car), then builds the archives. Null on /// success. - string? CloneCar(string source, string name, bool traffic, out CarCloneInfo? result); + string? CloneCar(string source, string name, bool traffic, string? title, out CarCloneInfo? result); /// Packs one archive's working copy back into its .sds, keeping a backup of what it replaces — for an /// edit made in the working copy itself (a script, a table) that no editor session tracks. Null on success. diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index caf6811..68e982b 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -163,18 +163,19 @@ public static async Task CarTuningSet( } [McpServerTool(Name = "car_clone")] - [Description("Make a new car out of an existing one for single player: copies pc\\sds\\cars\\.sds (and its winter _z twin) to .sds with the root frame, prefab entry and entity data renamed, adds the car to vehicles.tbl under a new id (class, price and flags of the source), to PaintCombinations.tbl and AiProps, and — with traffic — to every traffic row that can pick the source. Then builds the new archives plus tables.sds and ingame.sds (backups kept). The game must not be running. Open the clone with resource_open and tune it with car_tuning_set.")] + [Description("Make a new car out of an existing one for single player: copies pc\\sds\\cars\\.sds (and its winter _z twin) to .sds with the root frame, prefab entry, entity data and geometry buffers renamed, adds the car to vehicles.tbl under a new id (class, price and flags of the source), to PaintCombinations.tbl and AiProps, and — with traffic — to every traffic row that can pick the source. Then builds the new archives plus tables.sds and ingame.sds (backups kept). The game must not be running. Open the clone with resource_open and tune it with car_tuning_set.")] public static async Task CarClone( IEditorSession editor, IUiThreadMarshal ui, [Description("The car to copy, by archive or model name, e.g. 'shubert_38'.")] string source, [Description("The new model name: a letter, then letters, digits and '_', at most 31 characters, e.g. 'Shubert_38_Sport'.")] string name, - [Description("Let traffic pick the clone wherever it picks the source. Default true.")] bool traffic = true) + [Description("Let traffic pick the clone wherever it picks the source. Default true.")] bool traffic = true, + [Description("The name the game shows for the car (garage, shop), e.g. 'Shubert 38 Custom'. Written into the text table of every installed language under a new text id. Omit to share the source car's name.")] string? title = null) { try { CarCloneInfo? result = null; - string? refused = await ui.RunAsync(() => editor.CloneCar(source, name, traffic, out result)); + string? refused = await ui.RunAsync(() => editor.CloneCar(source, name, traffic, title, out result)); return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, clone = result }); } catch (Exception ex) diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index 0322a53..63ab227 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -23,6 +23,7 @@ internal static class CarCloneProbes { private const string Source = "shubert_38"; private const string Name = "Shubert_38_Clone"; + private const string Title = "Shubert 38 Clone"; // Output: %TEMP%\illusion_car_clone.txt internal static void RunCarCloneProbe() @@ -53,21 +54,30 @@ void Check(string name, bool ok, string detail = "") var archive = new FileInfo(Path.Combine(sds, "cars", Source + suffix + ".sds")); if (archive.Exists) cars.Add((SdsMeshLoader.EnsureExtracted(archive), Path.Combine(scratch, "car" + suffix))); } - var folders = new CarCloneFolders(tables, ingame, cars); + var text = new List(); + foreach (string language in Directory.GetDirectories(MafiaEnvironment.PcFolder, "sds_*")) + { + var archive = new FileInfo(Path.Combine(language, "text", "text_default.sds")); + if (archive.Exists) text.Add(Copy(SdsMeshLoader.EnsureExtracted(archive), Path.Combine(scratch, "text_" + Path.GetFileName(language)))); + } + var folders = new CarCloneFolders(tables, ingame, cars, text); string vehiclesPath = Path.Combine(tables, "tables", "vehicles.tbl"); GameTable before = GameTable.Load(vehiclesPath); int sourceRow = before.FindRow(2, Source); int sourceId = (int)before.Cell(sourceRow, 0); Check("refuses a name that is not a model name", - CarCloner.CloneExtracted(folders, Source, "9 bad name", traffic: true, out string? bad) == null && bad != null, bad ?? ""); + CarCloner.CloneExtracted(folders, Source, "9 bad name", traffic: true, Title, out string? bad) == null && bad != null, bad ?? ""); Check("refuses a car the vehicle table does not list", - CarCloner.CloneExtracted(folders, "no_such_car", Name, traffic: true, out string? none) == null && none != null, none ?? ""); + CarCloner.CloneExtracted(folders, "no_such_car", Name, traffic: true, Title, out string? none) == null && none != null, none ?? ""); Check("refuses a name the table already has", - CarCloner.CloneExtracted(folders, Source, "Smith_V8", traffic: true, out string? taken) == null && taken != null, taken ?? ""); + CarCloner.CloneExtracted(folders, Source, "Smith_V8", traffic: true, Title, out string? taken) == null && taken != null, taken ?? ""); Check("a refusal writes nothing", !Directory.Exists(cars[0].To)); - CarCloneResult? result = CarCloner.CloneExtracted(folders, Source, Name, traffic: true, out string? refused); + Check("refuses a title that is not one short line", + CarCloner.CloneExtracted(folders, Source, Name, traffic: true, "two\nlines", out string? badTitle) == null && badTitle != null, badTitle ?? ""); + string[] textBefore = [.. text.Select(t => File.ReadAllText(Path.Combine(t, "tables", "TextDatabase.dat")))]; + CarCloneResult? result = CarCloner.CloneExtracted(folders, Source, Name, traffic: true, Title, out string? refused); Check("clones shubert_38", result != null, refused ?? ""); if (result == null) return; foreach (string note in result.Notes) sb.AppendLine(" note: " + note); @@ -77,8 +87,21 @@ void Check(string name, bool ok, string detail = "") Check("vehicles.tbl gains one row", vehicles.RowCount == before.RowCount + 1 && row >= 0); Check("under a fresh id", row >= 0 && (int)vehicles.Cell(row, 0) == result.VehicleId && Enumerable.Range(0, before.RowCount).All(i => (int)before.Cell(i, 0) != result.VehicleId), $"id {result.VehicleId}"); + // A name of its own: one new line per language, under an id nothing else uses, and the row points at it. + Check("the clone has a text id of its own", text.Count > 0 && result.TextId is { } own + && row >= 0 && (int)vehicles.Cell(row, 3) == own && own != (int)before.Cell(sourceRow, 3), $"id {result.TextId}"); + for (int t = 0; t < text.Count && result.TextId is { } titleId; t++) + { + string table = Path.Combine(text[t], "tables", "TextDatabase.dat"); + string after = File.ReadAllText(table); + string line = Illusion.Assets.Text.GameText.Key(titleId) + ":" + Title + "\r\n"; + Check($"{Path.GetFileName(text[t])}: the title is one added line, everything else as it was", + Illusion.Assets.Text.GameText.Find(table, titleId) == Title + && after.Length == textBefore[t].Length + line.Length && after.Replace(line, "") == textBefore[t]); + Check($"{Path.GetFileName(text[t])}: the text archive packs", Packs(text[t])); + } Check("with the source car's class, price and flags", - row >= 0 && Enumerable.Range(0, vehicles.ColumnCount).Where(c => c is not 0 and not 2) + row >= 0 && Enumerable.Range(0, vehicles.ColumnCount).Where(c => c is not 0 and not 2 and not 3) .All(c => Equals(vehicles.Cell(row, c), before.Cell(sourceRow, c)))); byte[] raw = File.ReadAllBytes(vehiclesPath); Check("the name cell stores FNV64 of the name beside it", @@ -121,6 +144,26 @@ void Check(string name, bool ok, string detail = "") var names = frame.FrameObjects.Values.OfType().Select(f => f.Name.String).ToList(); Check($"{label}: the root frame carries the new name", names.Contains(Name) && !names.Contains("Shubert_38")); + // Buffers of its own: none of the names the source car's pools hold, every LOD still resolved, + // and the same bytes buffer for buffer. + ExtractedSds cloned = ExtractedSds.Load(to); + ExtractedSds source = ExtractedSds.Load(cars.First(c => c.To == to).From); + var sourceNames = new HashSet(source.VertexBuffers.Buffers.Keys.Concat(source.IndexBuffers.Buffers.Keys)); + var cloneNames = cloned.VertexBuffers.Buffers.Keys.Concat(cloned.IndexBuffers.Buffers.Keys).ToList(); + Check($"{label}: the clone's buffers have names of their own", + cloneNames.Count == sourceNames.Count && cloneNames.Count > 0 && !cloneNames.Any(sourceNames.Contains), + $"{cloneNames.Count} buffers, {cloneNames.Count(sourceNames.Contains)} still shared"); + var lods = cloned.FrameResource!.FrameGeometries.Values.SelectMany(g => g.LOD ?? []).ToList(); + Check($"{label}: every level of detail finds its buffers", + lods.Count > 0 && lods.All(l => cloned.VertexBuffers.GetBuffer(l.VertexBufferRef.Hash) != null + && cloned.IndexBuffers.GetBuffer(l.IndexBufferRef.Hash) != null), $"{lods.Count} levels"); + Check($"{label}: the buffers hold the source's bytes, in the source's order", + cloned.VertexBuffers.Buffers.Values.Select(b => b.Data.Length) + .SequenceEqual(source.VertexBuffers.Buffers.Values.Select(b => b.Data.Length)) + && cloned.VertexBuffers.Buffers.Values.Zip(source.VertexBuffers.Buffers.Values) + .All(pair => pair.First.Data.AsSpan().SequenceEqual(pair.Second.Data)) + && cloned.IndexBuffers.Buffers.Values.Zip(source.IndexBuffers.Buffers.Values) + .All(pair => pair.First.GetData().AsSpan().SequenceEqual(pair.Second.GetData()))); // The name is written twice: as the root's name and as the root Frame's actor link. byte[] frameBytes = File.ReadAllBytes(manifest.GetFiles("FrameResource")[0]); Check($"{label}: nothing in the frames names the old car", @@ -174,7 +217,7 @@ void Check(string name, bool ok, string detail = "") Check("ingame.sds packs", Packs(ingame)); // Packing recompiles every XML resource; what it compiles has to decompile back to the same text, // or a Build of the table archives quietly rewrites configs nobody touched. - foreach (string folder in new[] { tables, ingame }) + foreach (string folder in new[] { tables, ingame }.Concat(text)) { string back = Path.Combine(scratch, Path.GetFileName(folder) + "_back"); SdsArchive archive = SdsArchive.Pack(folder, GameProfile.MafiaII); @@ -200,7 +243,7 @@ void Check(string name, bool ok, string detail = "") } // A Build prunes manifest entries whose file is gone; an XML resource sits on disk as name + ".xml", // and every one of them used to be pruned that way. - foreach (string folder in new[] { tables, ingame, cars[0].To }) + foreach (string folder in new[] { tables, ingame, cars[0].To }.Concat(text)) { List pruned = SdsWriter.PruneMissingEntries(folder); Check($"{Path.GetFileName(folder)}: a Build drops no manifest entry", pruned.Count == 0, diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 48a9211..0727f7c 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -812,14 +812,14 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) } } - public string? CloneCar(string source, string name, bool traffic, out CarCloneInfo? result) + public string? CloneCar(string source, string name, bool traffic, string? title, out CarCloneInfo? result) { result = null; if (EnsureEnvironment() is { } notReady) return notReady; try { - if (Assets.Cars.CarCloner.Clone(source, name, traffic, out string? refusal) is not { } outcome) return refusal; - result = new CarCloneInfo(outcome.Name, outcome.VehicleId, outcome.TrafficRows, + if (Assets.Cars.CarCloner.Clone(source, name, traffic, title, out string? refusal) is not { } outcome) return refusal; + result = new CarCloneInfo(outcome.Name, outcome.VehicleId, outcome.TrafficRows, outcome.TextId, [.. outcome.Packed.Select(p => new PackedArchive(p.Archive, p.Backup))], outcome.Notes); return null; } From e99a0d2fd82ca73d80ade17d82155bb59c0e1859 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:43:51 +0300 Subject: [PATCH 032/102] Packer keeps shipped memory requirements; export a car as an M2O resource A rebuilt archive stated payload sizes where the shipped one stated what the engine should budget (a car asked for 148 079 bytes of slot RAM against 170 232, and no 'other' RAM). The figures are now read from the archive as it shipped, kept beside the working copy (illusion_memory.json) and applied on pack; a clone carries its source car's. archive_build takes memoryFrom for a clone built before that. car_export_m2o writes a resource folder for Mafia II Online: package.json, cars/.sds (+ _z) and vehicles.json with the model, titles, source car, key hashes and the vehicle/paint table rows. Probes: --probe-car-clone (memory), --probe-car-m2o. --- src/Illusion.Assets/Cars/CarCloner.cs | 25 ++ src/Illusion.Assets/Cars/CarM2oExport.cs | 395 ++++++++++++++++++ .../Cars/CarM2oExportResult.cs | 16 + .../Cars/CarM2oExportSources.cs | 15 + src/Illusion.Assets/Sds/SdsWriter.cs | 53 ++- src/Illusion.Formats/Archive/SdsArchive.cs | 12 +- .../Archive/SdsMemoryRequirement.cs | 5 + .../Archive/SdsMemoryRequirements.cs | 178 ++++++++ src/Illusion.Mcp/EditorModels.cs | 6 + src/Illusion.Mcp/IEditorSession.cs | 10 +- src/Illusion.Mcp/Tools/EditorTools.cs | 26 +- src/Illusion/Diagnostics/ProbeRunner.cs | 5 + .../Diagnostics/Probes/CarCloneProbes.cs | 55 +++ .../Diagnostics/Probes/CarM2oExportProbes.cs | 206 +++++++++ src/Illusion/Diagnostics/Probes/McpProbes.cs | 1 + src/Illusion/Mcp/AppEditorSession.cs | 31 +- 16 files changed, 1031 insertions(+), 8 deletions(-) create mode 100644 src/Illusion.Assets/Cars/CarM2oExport.cs create mode 100644 src/Illusion.Assets/Cars/CarM2oExportResult.cs create mode 100644 src/Illusion.Assets/Cars/CarM2oExportSources.cs create mode 100644 src/Illusion.Formats/Archive/SdsMemoryRequirement.cs create mode 100644 src/Illusion.Formats/Archive/SdsMemoryRequirements.cs create mode 100644 src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs index 3aac79d..7cb985e 100644 --- a/src/Illusion.Assets/Cars/CarCloner.cs +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -122,6 +122,10 @@ public static partial class CarCloner [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.ExtractedDir(c.To)))], [.. textSds.Select(SdsMeshLoader.EnsureExtracted)]); + // The clone's resources bear the source's names, so what the source asks the engine to budget is what + // the clone asks too — written beside the source's working copy here, it is copied with the folder. + foreach ((FileInfo from, FileInfo _) in cars) SdsWriter.EnsureMemoryRequirements(from); + // A folder left behind by an attempt that never produced its archive is nobody's working copy. foreach ((string _, string to) in folders.Cars) SdsWriter.DeleteExtracted(to); @@ -317,11 +321,32 @@ private static bool CopyCoverPoints(string ingame, string model, string name) return true; } + /// The note a clone's working copy keeps of the car it was made from. + public const string ProvenanceFile = "illusion_clone.json"; + + /// The model a cloned car's working copy says it was made from, or null. + public static string? SourceOf(string folder) + { + string path = Path.Combine(folder, ProvenanceFile); + if (!File.Exists(path)) return null; + try + { + using var document = System.Text.Json.JsonDocument.Parse(File.ReadAllText(path)); + return document.RootElement.TryGetProperty("source", out System.Text.Json.JsonElement source) ? source.GetString() : null; + } + catch (System.Text.Json.JsonException) + { + return null; + } + } + // The root frame takes the new name (and with it the name table), the prefab entry and the entity-data // storage follow it under its new hash. private static List RenameInside(string folder, string model, string name) { var notes = new List(); + File.WriteAllText(Path.Combine(folder, ProvenanceFile), + System.Text.Json.JsonSerializer.Serialize(new Dictionary { ["source"] = model, ["model"] = name })); SdsManifest manifest = SdsManifest.Load(folder); string label = Path.GetFileName(folder); diff --git a/src/Illusion.Assets/Cars/CarM2oExport.cs b/src/Illusion.Assets/Cars/CarM2oExport.cs new file mode 100644 index 0000000..999d46b --- /dev/null +++ b/src/Illusion.Assets/Cars/CarM2oExport.cs @@ -0,0 +1,395 @@ +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; +using Illusion.Assets.Sds; +using Illusion.Assets.Text; +using Illusion.Formats.Archive; +using Illusion.Formats.EntityData; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Hashing; +using Illusion.Formats.Prefab; +using Illusion.Formats.ResourceFormats; + +namespace Illusion.Assets.Cars; + +/// +/// A car as a resource a Mafia II Online server can ship: a folder with the resource's package.json, the +/// car's archive under cars/ (and its winter twin) and vehicles.json, which says what the +/// archives hold. +/// +/// +/// Only what can travel goes in. The single-player registration — the row in vehicles.tbl, the paint +/// combinations, the title in the text table — sits in archives every player already has and the multiplayer +/// replaces with its own, so it is written into vehicles.json as data instead: the model name the +/// archive is keyed by, the title, the car it was made from, and the table rows as the game holds them. +/// +/// +/// A car is exported only when its archive is its own: the root frame, the first name-table entry, the prefab +/// entry and the entity data all filed under the model name. A folder can hold several cars — a second export +/// into it adds to the list. +/// +/// +public static partial class CarM2oExport +{ + /// The list of cars a resource folder holds. + public const string VehiclesFile = "vehicles.json"; + + /// The resource's manifest, as the multiplayer reads it. + public const string PackageFile = "package.json"; + + /// The version of 's layout. + public const int Format = 1; + + private const string CarsFolder = "cars"; + private const int VehicleIdColumn = 0; + private const int VehicleNameColumn = 2; + private const int VehicleTextColumn = 3; + private const int PaintNameColumn = 1; + + private static readonly JsonSerializerOptions Indented = new() + { + WriteIndented = true, + Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + }; + + [GeneratedRegex("^[a-z0-9][a-z0-9._-]{0,63}$")] + private static partial Regex ResourcePattern(); + + /// The resource name a car gets when none is given: car-shubert-38-custom. + public static string DefaultResource(string car) => + "car-" + Path.GetFileNameWithoutExtension(car).ToLowerInvariant().Replace('_', '-'); + + /// + /// Exports a car of the game the toolkit has open. is its archive or model name; + /// the archive exported is the one in pc\sds\cars as it stands, so build the car first. Without + /// the folder is <game>\_illusion_export\m2o\<resource>. + /// Nothing of the game is written. + /// + public static CarM2oExportResult? Export(string car, string? output, string? resource, out string? refusal) + { + ArgumentNullException.ThrowIfNull(car); + if (!MafiaEnvironment.IsInitialized) + { + refusal = "the game folder is not set"; + return null; + } + string stem = Path.GetFileNameWithoutExtension(car).ToLowerInvariant(); + string sds = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + var archive = new FileInfo(Path.Combine(sds, "cars", stem + ".sds")); + if (!archive.Exists) + { + refusal = $"there is no car archive {archive.Name} in pc\\sds\\cars"; + return null; + } + var winter = new FileInfo(Path.Combine(sds, "cars", stem + "_z.sds")); + var tables = new FileInfo(Path.Combine(sds, "tables", "tables.sds")); + var ingame = new FileInfo(Path.Combine(sds, "tables", "ingame.sds")); + var text = new List<(string, string)>(); + foreach (string language in Directory.GetDirectories(MafiaEnvironment.PcFolder, "sds_*")) + { + var textSds = new FileInfo(Path.Combine(language, "text", "text_default.sds")); + if (!textSds.Exists) continue; + text.Add((Path.GetFileName(language)["sds_".Length..], + Path.Combine(SdsMeshLoader.EnsureExtracted(textSds), "tables", "TextDatabase.dat"))); + } + + string? basedOn = CarCloner.SourceOf(MafiaEnvironment.ExtractedDir(archive)); + var basedOnArchive = basedOn == null ? null : new FileInfo(Path.Combine(sds, "cars", basedOn.ToLowerInvariant() + ".sds")); + resource ??= DefaultResource(stem); + output ??= Path.Combine(MafiaEnvironment.GameRoot, "_illusion_export", "m2o", resource); + var sources = new CarM2oExportSources( + archive, + winter.Exists ? winter : null, + tables.Exists ? Path.Combine(SdsMeshLoader.EnsureExtracted(tables), "tables", "vehicles.tbl") : null, + ingame.Exists ? Path.Combine(SdsMeshLoader.EnsureExtracted(ingame), "tables", "PaintCombinations.tbl") : null, + text, + basedOn, + basedOnArchive is { Exists: true } ? basedOnArchive : null); + return ExportFrom(sources, output, resource, out refusal); + } + + /// + /// The export itself, from named sources into . Refuses — writing nothing — a + /// resource name the multiplayer would not take, a folder that holds something other than an export, and an + /// archive that is not filed under its own name throughout. + /// + public static CarM2oExportResult? ExportFrom(CarM2oExportSources sources, string output, string resource, out string? refusal) + { + ArgumentNullException.ThrowIfNull(sources); + ArgumentNullException.ThrowIfNull(output); + ArgumentNullException.ThrowIfNull(resource); + if (!ResourcePattern().IsMatch(resource)) + { + refusal = $"'{resource}' — a resource name is lower-case letters, digits, '-', '_' and '.', starting with a letter or digit"; + return null; + } + if (!sources.Archive.Exists) + { + refusal = $"no such archive: {sources.Archive.FullName}"; + return null; + } + string vehiclesFile = Path.Combine(output, VehiclesFile); + if (Directory.Exists(output) && Directory.EnumerateFileSystemEntries(output).Any() && !File.Exists(vehiclesFile)) + { + refusal = $"{output} holds something that is not a car export — pick an empty folder"; + return null; + } + + string stem = Path.GetFileNameWithoutExtension(sources.Archive.Name); + var notes = new List(); + + // The vehicle table names the model as the archive's insides must; an unregistered car is named by its frame. + GameTable? vehicles = sources.VehiclesTable != null && File.Exists(sources.VehiclesTable) + ? GameTable.Load(sources.VehiclesTable) + : null; + int row = vehicles?.FindRow(VehicleNameColumn, stem) ?? -1; + string? listed = row >= 0 ? (string)vehicles!.Cell(row, VehicleNameColumn) : null; + + Inspection car = Inspect(sources.Archive, listed, stem); + if (car.Model == null || car.Problems.Count > 0) + { + refusal = $"{sources.Archive.Name} is not a car of its own: {string.Join("; ", car.Problems)}"; + return null; + } + string model = car.Model; + if (sources.WinterArchive is { } winterArchive) + { + Inspection winter = Inspect(winterArchive, model, stem); + if (winter.Problems.Count > 0) + { + refusal = $"{winterArchive.Name} is not the same car: {string.Join("; ", winter.Problems)}"; + return null; + } + if (!winter.ShippedMemory) notes.Add(PackerFigures(winterArchive.Name)); + } + if (!car.ShippedMemory) notes.Add(PackerFigures(sources.Archive.Name)); + if (sources.BasedOnArchive is { Exists: true } baseArchive) + { + int shared = Inspect(baseArchive, sources.BasedOn, Path.GetFileNameWithoutExtension(baseArchive.Name)).Buffers + .Count(car.Buffers.Contains); + if (shared > 0) + { + notes.Add($"{shared} geometry buffers bear the names {sources.BasedOn}'s do — with both cars loaded, one draws the other's shape"); + } + } + + var entry = new JsonObject + { + ["model"] = model, + ["archive"] = $"{CarsFolder}/{sources.Archive.Name}", + ["winterArchive"] = sources.WinterArchive != null ? JsonValue.Create($"{CarsFolder}/{sources.WinterArchive.Name}") : null, + }; + + // The title, in every language that has one. + string? title = null; + if (row >= 0 && vehicles!.Cell(row, VehicleTextColumn) is int textId) + { + var titles = new JsonObject(); + foreach ((string language, string table) in sources.Text) + { + if (!File.Exists(table) || GameText.Find(table, textId) is not { } found) continue; + titles[language] = found; + if (title == null || language.Equals("en", StringComparison.OrdinalIgnoreCase)) title = found; + } + entry["title"] = title; + entry["titles"] = titles; + } + entry["basedOn"] = sources.BasedOn; + entry["hashes"] = new JsonObject + { + ["model"] = Hex(Fnv64.Hash(model)), + ["entityData"] = Hex(Fnv64.Hash(model.ToLowerInvariant())), + }; + + if (row >= 0) + { + entry["vehicleTable"] = RowJson(vehicles!, row, (int)vehicles!.Cell(row, VehicleIdColumn)); + } + else + { + notes.Add($"vehicles.tbl lists no car named {stem} — the export carries no table row"); + } + if (sources.PaintTable != null && File.Exists(sources.PaintTable)) + { + GameTable paint = GameTable.Load(sources.PaintTable); + int paintRow = paint.FindRow(PaintNameColumn, model); + if (paintRow >= 0) entry["paintCombinations"] = RowJson(paint, paintRow, id: null); + } + + // Everything is known; now the folder. + string cars = Path.Combine(output, CarsFolder); + Directory.CreateDirectory(cars); + var written = new List(); + var files = new JsonArray(); + foreach (FileInfo? source in new[] { sources.Archive, sources.WinterArchive }) + { + if (source == null) continue; + string target = Path.Combine(cars, source.Name); + File.Copy(source.FullName, target, overwrite: true); + written.Add(target); + files.Add(new JsonObject + { + ["path"] = $"{CarsFolder}/{source.Name}", + ["size"] = new FileInfo(target).Length, + ["sha256"] = Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(target))), + }); + } + entry["files"] = files; + + JsonArray list = ReadList(vehiclesFile); + for (int i = list.Count - 1; i >= 0; i--) + { + if (string.Equals(list[i]?["model"]?.GetValue(), model, StringComparison.OrdinalIgnoreCase)) list.RemoveAt(i); + } + list.Add(entry); + var document = new JsonObject + { + ["format"] = Format, + ["about"] = "Car models exported by Illusion Toolkit. Each archive is filed under its model name throughout " + + "(root frame, name table, prefab entry, entity data, geometry buffers); vehicleTable and " + + "paintCombinations are the rows the single-player game registers the model with.", + ["vehicles"] = list, + }; + WriteJson(vehiclesFile, document); + written.Add(vehiclesFile); + + string packageFile = Path.Combine(output, PackageFile); + WritePackage(packageFile, resource, model, title); + written.Add(packageFile); + + refusal = null; + return new CarM2oExportResult(output, resource, model, title, sources.BasedOn, list.Count, written, notes); + } + + private static string PackerFigures(string archive) => + $"{archive} states a packer's memory figures, less than a shipped car asks for — build it with the car it was made from as the memory reference before shipping"; + + private static string Hex(ulong hash) => "0x" + hash.ToString("x16"); + + private static JsonObject RowJson(GameTable table, int row, int? id) + { + var columns = new JsonArray(); + var values = new JsonArray(); + for (int c = 0; c < table.ColumnCount; c++) + { + columns.Add(table.ColumnType(c)); + values.Add(table.Cell(row, c) switch + { + int i => JsonValue.Create(i), + uint u => JsonValue.Create(u), + float f => JsonValue.Create(f), + bool b => JsonValue.Create(b), + ulong h => JsonValue.Create(Hex(h)), + object other => JsonValue.Create(other.ToString()), + }); + } + var json = new JsonObject(); + if (id is { } given) json["id"] = given; + json["columns"] = columns; + json["values"] = values; + return json; + } + + // The cars a folder already lists; a file that does not read as one starts the list over. + private static JsonArray ReadList(string vehiclesFile) + { + if (!File.Exists(vehiclesFile)) return []; + try + { + if (JsonNode.Parse(File.ReadAllText(vehiclesFile))?["vehicles"] is JsonArray existing) + { + return [.. existing.Select(v => v?.DeepClone())]; + } + } + catch (JsonException) + { + // not a list — replaced + } + return []; + } + + // A package.json somebody already wrote keeps what it says; it only has to ship the cars. + private static void WritePackage(string packageFile, string resource, string model, string? title) + { + JsonObject? package = null; + if (File.Exists(packageFile)) + { + try + { + package = JsonNode.Parse(File.ReadAllText(packageFile)) as JsonObject; + } + catch (JsonException) + { + // not a manifest — replaced + } + } + package ??= new JsonObject + { + ["name"] = resource, + ["version"] = "1.0.0", + ["description"] = $"Vehicle model {model}{(title != null ? $" ({title})" : "")}", + }; + if (package["mafiahub"] is not JsonObject hub) package["mafiahub"] = hub = new JsonObject { ["priority"] = 0 }; + if (hub["files"] is not JsonArray shipped) hub["files"] = shipped = []; + foreach (string pattern in new[] { $"{CarsFolder}/**", VehiclesFile }) + { + if (!shipped.Any(f => f?.GetValueKind() == JsonValueKind.String && f.GetValue() == pattern)) shipped.Add(pattern); + } + WriteJson(packageFile, package); + } + + private static void WriteJson(string path, JsonNode node) => + AtomicFile.WriteAllBytes(path, new System.Text.UTF8Encoding(false).GetBytes(node.ToJsonString(Indented) + "\n")); + + private sealed record Inspection(string? Model, List Problems, HashSet Buffers, bool ShippedMemory); + + // What the archive itself says: whether every key in it is the model's, the names its buffers bear, and + // whether it states memory figures like a shipped archive. + private static Inspection Inspect(FileInfo archive, string? model, string stem) + { + string scratch = Path.Combine(Path.GetTempPath(), "illusion_m2o_" + Guid.NewGuid().ToString("N")); + try + { + SdsArchive opened = SdsArchive.Open(archive.FullName); + bool shipped = SdsMemoryRequirements.Extract(opened, scratch).LooksShipped; + ExtractedSds loaded = ExtractedSds.Load(scratch); + var problems = new List(); + List frames = loaded.FrameResource?.FrameObjects.Values.OfType() + .Select(f => f.Name.String ?? "").ToList() ?? []; + model ??= frames.FirstOrDefault(n => string.Equals(n, stem, StringComparison.OrdinalIgnoreCase)); + var buffers = new HashSet(loaded.VertexBuffers.Buffers.Keys.Concat(loaded.IndexBuffers.Buffers.Keys)); + if (model == null) + { + problems.Add($"no frame in it is named {stem}"); + return new Inspection(null, problems, buffers, shipped); + } + + if (!frames.Contains(model)) problems.Add($"no frame in it is named {model}"); + FrameNameTable.Data[] listed = loaded.FrameNameTable?.FrameData ?? []; + if (listed.Length == 0 || listed[0].Name != model) + { + problems.Add($"its name table starts with {(listed.Length > 0 ? listed[0].Name : "nothing")}, not {model}"); + } + foreach (string path in loaded.Manifest.GetFiles("PREFAB")) + { + if (!PrefabFile.Load(path).Contains(Fnv64.Hash(model))) problems.Add($"its prefab has no entry for {model}"); + } + foreach (string path in loaded.Manifest.GetFiles("EntityDataStorage")) + { + if (EntityDataStorageFile.Load(path).Hash != Fnv64.Hash(model.ToLowerInvariant())) + { + problems.Add($"its entity data is not filed under {model.ToLowerInvariant()}"); + } + } + return new Inspection(model, problems, buffers, shipped); + } + finally + { + try { if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); } + catch (IOException) { /* scratch left behind */ } + } + } +} diff --git a/src/Illusion.Assets/Cars/CarM2oExportResult.cs b/src/Illusion.Assets/Cars/CarM2oExportResult.cs new file mode 100644 index 0000000..c69eed5 --- /dev/null +++ b/src/Illusion.Assets/Cars/CarM2oExportResult.cs @@ -0,0 +1,16 @@ +namespace Illusion.Assets.Cars; + +/// +/// A car exported as a multiplayer resource: the folder, the resource's name, the model and the title it is +/// listed under, the car it was cloned from, how many cars the folder now lists, the files written and what the +/// one shipping it should know. +/// +public sealed record CarM2oExportResult( + string Folder, + string Resource, + string Model, + string? Title, + string? BasedOn, + int Vehicles, + IReadOnlyList Files, + IReadOnlyList Notes); diff --git a/src/Illusion.Assets/Cars/CarM2oExportSources.cs b/src/Illusion.Assets/Cars/CarM2oExportSources.cs new file mode 100644 index 0000000..dfb6406 --- /dev/null +++ b/src/Illusion.Assets/Cars/CarM2oExportSources.cs @@ -0,0 +1,15 @@ +namespace Illusion.Assets.Cars; + +/// +/// What a multiplayer export of one car is made from: its built archive (and the winter twin, when it has +/// one), the vehicle and paint tables it is registered in, the text table of each language, and the car it was +/// cloned from with that car's archive. +/// +public sealed record CarM2oExportSources( + FileInfo Archive, + FileInfo? WinterArchive, + string? VehiclesTable, + string? PaintTable, + IReadOnlyList<(string Language, string Table)> Text, + string? BasedOn, + FileInfo? BasedOnArchive); diff --git a/src/Illusion.Assets/Sds/SdsWriter.cs b/src/Illusion.Assets/Sds/SdsWriter.cs index 9ef1e2b..aaacae4 100644 --- a/src/Illusion.Assets/Sds/SdsWriter.cs +++ b/src/Illusion.Assets/Sds/SdsWriter.cs @@ -125,6 +125,57 @@ public static string SaveFrameResource(FrameResource frame, FileInfo sds) return files[0]; } + /// + /// What the resources of ask the engine to budget for them. Kept beside the working + /// copy once known; read the first time from the archive as it shipped — its oldest backup, or the archive + /// itself when it was never built. Figures that read like a packer's own (payload sizes, no "other" RAM) + /// are not kept, so an archive the toolkit made earlier does not pass its under-statement on. + /// + /// Null when nothing trustworthy is known — the packing handlers' own figures are used then. + internal static SdsMemoryRequirements? MemoryFor(FileInfo sds, string extracted) + { + if (SdsMemoryRequirements.Load(extracted) is { } known) return known; + + IReadOnlyList backups = ListBackups(sds); + sds.Refresh(); + FileInfo? shipped = backups.Count > 0 ? backups[^1].File : sds.Exists ? sds : null; + if (shipped == null) return null; + try + { + SdsMemoryRequirements read = SdsMemoryRequirements.FromArchive(shipped.FullName); + if (!read.LooksShipped) return null; + read.Save(extracted); + return read; + } + catch (Exception ex) when (ex is IOException or FileFormatException or UnauthorizedAccessException) + { + return null; + } + } + + /// Makes sure the working copy of knows its memory requirements — what a + /// copy of that folder (a cloned car) then carries with it. + public static bool EnsureMemoryRequirements(FileInfo sds) + { + ArgumentNullException.ThrowIfNull(sds); + return MemoryFor(sds, MafiaEnvironment.ExtractedDir(sds)) != null; + } + + /// + /// Gives the working copy of the memory requirements + /// states — for an archive that never shipped itself: a clone made before requirements were kept takes + /// them from the car it was cloned from, whose resources bear the same names. + /// + /// How many resources the reference stated requirements for. + public static int AdoptMemoryRequirements(FileInfo sds, FileInfo reference) + { + ArgumentNullException.ThrowIfNull(sds); + ArgumentNullException.ThrowIfNull(reference); + SdsMemoryRequirements read = SdsMemoryRequirements.FromArchive(reference.FullName); + read.Save(MafiaEnvironment.ExtractedDir(sds)); + return read.Count; + } + /// The folder timestamped backups of are written to: a backups subfolder /// beside the archive (e.g. …\sds\city\backups). public static string BackupDir(FileInfo sds) @@ -283,7 +334,7 @@ public static PackResult PackSds(FileInfo sds, bool createBackup, DateTime when) // caller before the temp is ever moved over the live archive. var tmp = new FileInfo(sds.FullName + ".tmp"); if (tmp.Exists) tmp.Delete(); - SdsArchive archive = SdsArchive.Pack(extracted, GameProfile.MafiaII); + SdsArchive archive = SdsArchive.Pack(extracted, GameProfile.MafiaII, MemoryFor(sds, extracted)); using (FileStream output = File.Create(tmp.FullName)) { archive.Save(output, new SdsWriteOptions()); diff --git a/src/Illusion.Formats/Archive/SdsArchive.cs b/src/Illusion.Formats/Archive/SdsArchive.cs index 7dbbc97..425a579 100644 --- a/src/Illusion.Formats/Archive/SdsArchive.cs +++ b/src/Illusion.Formats/Archive/SdsArchive.cs @@ -229,7 +229,10 @@ public List ResolveEntryNames() /// Builds an archive from an extracted folder (its SDSContent.xml manifest drives the entry list; /// the manifest itself is re-sorted on disk first, matching how the game orders resources). /// - public static SdsArchive Pack(string extractedFolder, GameProfile profile) + /// What each resource asks the engine to budget for it, as the shipped archive stated + /// it. Without it an entry states what its packing handler can measure — the payload size — which is less + /// than the game's own archives ask for. + public static SdsArchive Pack(string extractedFolder, GameProfile profile, SdsMemoryRequirements? memory = null) { string manifestPath = Path.Combine(extractedFolder, "SDSContent.xml"); if (!File.Exists(manifestPath)) @@ -267,6 +270,9 @@ public static SdsArchive Pack(string extractedFolder, GameProfile profile) { entryNodes.Add(iterator.Current!.Clone()); } + // Keys are taken in MANIFEST order — an entry without a file is told apart by its place among its type. + var ordinals = new Dictionary(StringComparer.Ordinal); + List keys = [.. entryNodes.Select(node => SdsMemoryRequirements.KeyOf(node.Clone(), ordinals))]; var ordered = entryNodes .Select((node, index) => { @@ -278,7 +284,7 @@ public static SdsArchive Pack(string extractedFolder, GameProfile profile) .ThenBy(e => e.Index) .ToList(); - foreach ((XPathNavigator entryNode, string typeName, int _) in ordered) + foreach ((XPathNavigator entryNode, string typeName, int manifestIndex) in ordered) { XPathNavigator nav = entryNode; nav.MoveToFirstChild(); @@ -317,6 +323,8 @@ public static SdsArchive Pack(string extractedFolder, GameProfile profile) throw new ResourcePackException($"failed to pack a '{typeName}' entry: {ex.Message}", ex); } + memory?.Apply(keys[manifestIndex], entry); + infoNode.AppendChild(typeNameNode); infoNode.AppendChild(descNode); infoNode.AppendChild(RamElement(infoDoc, "SlotRamRequired", (int)entry.SlotRamRequired)); diff --git a/src/Illusion.Formats/Archive/SdsMemoryRequirement.cs b/src/Illusion.Formats/Archive/SdsMemoryRequirement.cs new file mode 100644 index 0000000..af2141c --- /dev/null +++ b/src/Illusion.Formats/Archive/SdsMemoryRequirement.cs @@ -0,0 +1,5 @@ +namespace Illusion.Formats.Archive; + +/// What one resource asks the engine to budget for it — the four RAM/VRAM figures its archive entry +/// carries — and the payload size those figures were stated for. +public sealed record SdsMemoryRequirement(uint SlotRam, uint SlotVram, uint OtherRam, uint OtherVram, int DataSize); diff --git a/src/Illusion.Formats/Archive/SdsMemoryRequirements.cs b/src/Illusion.Formats/Archive/SdsMemoryRequirements.cs new file mode 100644 index 0000000..53a49d2 --- /dev/null +++ b/src/Illusion.Formats/Archive/SdsMemoryRequirements.cs @@ -0,0 +1,178 @@ +using System.Text.Json; +using System.Xml.XPath; + +namespace Illusion.Formats.Archive; + +/// +/// The memory each resource of an archive asks for, as the archive that shipped states it. +/// +/// +/// An entry carries four figures the engine budgets a slot by, and they are NOT the payload size: a shipped +/// car's PREFAB is 23 851 bytes and asks for 41 030, its FrameResource 73 711 and asks for 77 812 plus 853 of +/// "other" RAM. Extraction does not keep them and a packing handler can only state the payload size, so a +/// rebuilt archive under-declares itself — a car came out asking for 148 100 bytes of slot RAM where the +/// original asked for 170 232. These are read back from the archive that shipped and kept beside the working +/// copy (), so a rebuild states what the original did. +/// +/// +/// A resource is found by its type and the file name its manifest entry gives it; a container entry, which +/// names no file, by its place among the entries of its type. +/// +/// +public sealed class SdsMemoryRequirements +{ + /// The file beside SDSContent.xml the requirements are kept in. + public const string FileName = "illusion_memory.json"; + + private readonly Dictionary _byKey; + + private SdsMemoryRequirements(Dictionary byKey) => _byKey = byKey; + + public int Count => _byKey.Count; + + /// + /// Whether these read like an archive the game shipped rather than one a packer made: a packer states + /// payload sizes and no "other" RAM at all, so figures like that are not worth keeping. + /// + public bool LooksShipped => _byKey.Values.Any(r => r.OtherRam > 0 || r.SlotRam > (uint)r.DataSize + 256); + + public bool TryGet(string key, out SdsMemoryRequirement requirement) => _byKey.TryGetValue(key, out requirement!); + + /// The key of a manifest entry: its type and file, or its type and ordinal when it names no file. + public static string Key(string type, string? file, int ordinal) => + file != null ? $"{type}|{file}" : $"{type}#{ordinal}"; + + /// The keys of a folder's manifest entries, in manifest order. + public static List ManifestKeys(string folder) + { + var keys = new List(); + var ordinals = new Dictionary(StringComparer.Ordinal); + using FileStream stream = File.OpenRead(Path.Combine(folder, "SDSContent.xml")); + XPathNodeIterator entries = new XPathDocument(stream).CreateNavigator().Select("/SDSResource/ResourceEntry"); + while (entries.MoveNext()) + { + keys.Add(KeyOf(entries.Current!, ordinals)); + } + return keys; + } + + /// The key of one manifest entry; counts the entries of each type seen so far. + public static string KeyOf(XPathNavigator entry, Dictionary ordinals) + { + string type = ""; + string? file = null; + XPathNodeIterator children = entry.SelectChildren(XPathNodeType.Element); + while (children.MoveNext()) + { + if (children.Current!.Name == "Type") type = children.Current.Value; + else if (children.Current.Name == "File" && file == null) file = children.Current.Value; + } + int ordinal = ordinals.GetValueOrDefault(type); + ordinals[type] = ordinal + 1; + return Key(type, file, ordinal); + } + + /// + /// Reads what an archive states for each of its resources. The archive is extracted to a scratch folder to + /// learn the file names its entries get — the names a working copy's manifest uses. + /// + public static SdsMemoryRequirements FromArchive(string archivePath) + { + SdsArchive archive = SdsArchive.Open(archivePath); + string scratch = Path.Combine(Path.GetTempPath(), "illusion_memory_" + Guid.NewGuid().ToString("N")); + try + { + return Extract(archive, scratch); + } + finally + { + try { if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); } + catch (IOException) { /* scratch left behind */ } + } + } + + /// + /// Extracts into and returns what it stated for + /// each resource, keyed by the manifest the extraction wrote. The payload sizes are taken BEFORE the + /// extraction: unwrapping a texture leaves the entry holding the bare image, ten bytes short of the payload + /// the figures were stated for. + /// + public static SdsMemoryRequirements Extract(SdsArchive archive, string targetFolder) + { + ArgumentNullException.ThrowIfNull(archive); + // An entry of a type the reader does not know is not extracted, and so has no manifest entry. + var stated = archive.Entries.Where(e => e.TypeId != -1) + .Select(e => new SdsMemoryRequirement(e.SlotRamRequired, e.SlotVramRequired, e.OtherRamRequired, e.OtherVramRequired, e.Data?.Length ?? 0)) + .ToList(); + archive.Extract(targetFolder); + List keys = ManifestKeys(targetFolder); + if (keys.Count != stated.Count) + { + throw new SdsFormatException( + $"the archive extracts to {keys.Count} manifest entries for its {stated.Count} resources"); + } + var byKey = new Dictionary(StringComparer.Ordinal); + for (int i = 0; i < keys.Count; i++) byKey[keys[i]] = stated[i]; + return new SdsMemoryRequirements(byKey); + } + + /// The requirements kept beside a working copy, or null when it has none. + public static SdsMemoryRequirements? Load(string folder) + { + string path = Path.Combine(folder, FileName); + if (!File.Exists(path)) return null; + try + { + var raw = JsonSerializer.Deserialize>(File.ReadAllText(path)); + if (raw == null) return null; + var byKey = new Dictionary(StringComparer.Ordinal); + foreach ((string key, uint[] v) in raw) + { + if (v.Length == 5) byKey[key] = new SdsMemoryRequirement(v[0], v[1], v[2], v[3], (int)v[4]); + } + return new SdsMemoryRequirements(byKey); + } + catch (JsonException) + { + return null; + } + } + + public void Save(string folder) + { + var raw = _byKey.ToDictionary( + p => p.Key, + p => new[] { p.Value.SlotRam, p.Value.SlotVram, p.Value.OtherRam, p.Value.OtherVram, (uint)p.Value.DataSize }); + string path = Path.Combine(folder, FileName); + string temp = path + ".tmp"; + File.WriteAllText(temp, JsonSerializer.Serialize(raw, new JsonSerializerOptions { WriteIndented = true })); + File.Move(temp, path, overwrite: true); + } + + /// + /// States for a freshly packed entry what the shipped one stated. With the payload the size it was, the + /// figures are taken as they are; with a payload that changed size they scale with it, and never fall + /// under what the packing handler measured from the new payload itself. + /// + public void Apply(string key, ResourceEntry entry) + { + ArgumentNullException.ThrowIfNull(entry); + if (!_byKey.TryGetValue(key, out SdsMemoryRequirement? stated)) return; + int size = entry.Data?.Length ?? 0; + if (size == stated.DataSize) + { + entry.SlotRamRequired = stated.SlotRam; + entry.SlotVramRequired = stated.SlotVram; + entry.OtherRamRequired = stated.OtherRam; + entry.OtherVramRequired = stated.OtherVram; + return; + } + double scale = stated.DataSize > 0 ? (double)size / stated.DataSize : 1.0; + entry.SlotRamRequired = Math.Max(entry.SlotRamRequired, Scaled(stated.SlotRam, scale)); + entry.OtherRamRequired = Math.Max(entry.OtherRamRequired, Scaled(stated.OtherRam, scale)); + entry.SlotVramRequired = Math.Max(entry.SlotVramRequired, Scaled(stated.SlotVram, scale)); + entry.OtherVramRequired = Math.Max(entry.OtherVramRequired, Scaled(stated.OtherVram, scale)); + } + + private static uint Scaled(uint value, double scale) => (uint)Math.Ceiling(value * scale); +} diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index 3ab3758..fe2b2f2 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -41,6 +41,12 @@ public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, i /// One archive a car clone wrote, and the backup of what it replaced. public sealed record PackedArchive(string Archive, string? Backup); +/// A car exported as a multiplayer resource: the folder and the resource's name, the model, its title +/// and the car it was cloned from, how many cars the folder lists now, the files written and what the one +/// shipping it should know. +public sealed record M2oExportInfo(string Folder, string Resource, string Model, string? Title, string? BasedOn, + int Vehicles, IReadOnlyList Files, IReadOnlyList Notes); + /// One entity-data table of a car — a car ships several (the stock one and its tuned variants); the /// label names its mass and power, which is what tells them apart. public sealed record TuningTableInfo(int Table, string Label, string Type, int Fields); diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index 9c7b061..6688013 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -140,8 +140,14 @@ public interface IEditorSession string? CloneCar(string source, string name, bool traffic, string? title, out CarCloneInfo? result); /// Packs one archive's working copy back into its .sds, keeping a backup of what it replaces — for an - /// edit made in the working copy itself (a script, a table) that no editor session tracks. Null on success. - string? BuildArchive(string archive, out PackedArchive? result); + /// edit made in the working copy itself (a script, a table) that no editor session tracks. With + /// , the working copy first takes the memory requirements that archive states + /// (a clone made before they were kept takes them from the car it was cloned from). Null on success. + string? BuildArchive(string archive, string? memoryFrom, out PackedArchive? result); + + /// Exports a built car as a multiplayer resource folder (package.json, cars/, vehicles.json); + /// nothing of the game is written. Null on success. + string? ExportCarForM2o(string car, string? output, string? resource, out M2oExportInfo? result); string? Undo(); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 68e982b..9d6c800 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -189,12 +189,13 @@ public static async Task CarClone( public static async Task ArchiveBuild( IEditorSession editor, IUiThreadMarshal ui, - [Description("Full path of the .sds archive in the game folder (pc\\sds\\… or pc\\dlcs\\…).")] string archive) + [Description("Full path of the .sds archive in the game folder (pc\\sds\\… or pc\\dlcs\\…).")] string archive, + [Description("Full path of an archive to take the per-resource memory requirements from, for an archive that never shipped itself: a cloned car built before requirements were kept takes them from the stock car it was cloned from. Omit normally — a working copy keeps the requirements of the archive it was extracted from.")] string? memoryFrom = null) { try { PackedArchive? result = null; - string? refused = await ui.RunAsync(() => editor.BuildArchive(archive, out result)); + string? refused = await ui.RunAsync(() => editor.BuildArchive(archive, memoryFrom, out result)); return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, packed = result }); } catch (Exception ex) @@ -203,6 +204,27 @@ public static async Task ArchiveBuild( } } + [McpServerTool(Name = "car_export_m2o")] + [Description("Export a built car as a Mafia II Online resource folder: package.json (mafiahub.files ships cars/** and vehicles.json), cars/.sds with its winter _z twin, and vehicles.json — model name, title per language, the car it was cloned from, the hashes the archive is keyed by, its vehicles.tbl and PaintCombinations rows, size and sha256 of each archive. Takes pc\\sds\\cars\\.sds as it stands (build first) and refuses an archive that is not filed under its own name throughout. A second export into the same folder adds to the list. Writes only the output folder — nothing of the game or of the multiplayer. The multiplayer cannot load a vehicle model from a resource yet: the folder is what its developer is asked to support.")] + public static async Task CarExportM2o( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The car, by archive or model name, e.g. 'shubert_38_custom'.")] string car, + [Description("Full path of the folder to write. Default: \\_illusion_export\\m2o\\. Must be empty or an earlier export.")] string? output = null, + [Description("The resource's name (lower-case letters, digits, '-', '_', '.'). Default: 'car-' + the car's name with '-' for '_'.")] string? resource = null) + { + try + { + M2oExportInfo? result = null; + string? refused = await ui.RunAsync(() => editor.ExportCarForM2o(car, output, resource, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, export = result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + [McpServerTool(Name = "editor_list_areas")] [Description("Names of the areas (districts and interiors) the map editor can load. Empty until the editor is open — editor_open_area opens it.")] public static async Task ListAreas(IEditorSession editor, IUiThreadMarshal ui) diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 9db1f74..113f96a 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -178,6 +178,11 @@ public static bool TryRun(string[] args) case "--probe-car-clone": CarCloneProbes.RunCarCloneProbe(); return true; + // A car exported as a multiplayer resource folder, from a clone made and packed on scratch copies. + // Output: %TEMP%\illusion_car_m2o.txt + case "--probe-car-m2o": + CarM2oExportProbes.RunCarM2oExportProbe(); + return true; // Buffers nothing draws from: none in the archives as shipped, how many in the working copies. // Output: %TEMP%\illusion_geometry_sweep.txt case "--probe-geometry-sweep": diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index 63ab227..aafaec1 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -241,6 +241,61 @@ void Check(string name, bool ok, string detail = "") Check($"{Path.GetFileName(folder)}: every XML survives a pack unchanged", xmls > 0 && changed.Count == 0, $"{changed.Count} of {xmls} differ: {string.Join(", ", changed.Take(5))}"); } + // What a rebuilt archive asks the engine to budget: the figures of the archive that shipped, not the + // payload sizes a packing handler can measure. + foreach (string suffix in new[] { "", "_z" }) + { + var shippedFile = new FileInfo(Path.Combine(sds, "cars", Source + suffix + ".sds")); + if (!shippedFile.Exists) continue; + string label = Source + suffix; + SdsArchive shipped = SdsArchive.Open(shippedFile.FullName); + string unpacked = Path.Combine(scratch, "memory" + suffix); + SdsMemoryRequirements stated = SdsMemoryRequirements.Extract(shipped, unpacked); + Check($"{label}: the shipped archive's figures read as shipped", stated.LooksShipped && stated.Count == shipped.Entries.Count, + $"{stated.Count} resources"); + + SdsArchive plain = SdsArchive.Pack(unpacked, GameProfile.MafiaII); + Check($"{label}: a packer left to itself asks for less", plain.SlotRamRequired < shipped.SlotRamRequired + && plain.OtherRamRequired == 0, $"slot RAM {plain.SlotRamRequired} against {shipped.SlotRamRequired}"); + + SdsArchive rebuilt = SdsArchive.Pack(unpacked, GameProfile.MafiaII, stated); + Check($"{label}: rebuilt with them it asks for what the shipped archive did", + (rebuilt.SlotRamRequired, rebuilt.SlotVramRequired, rebuilt.OtherRamRequired, rebuilt.OtherVramRequired) + == (shipped.SlotRamRequired, shipped.SlotVramRequired, shipped.OtherRamRequired, shipped.OtherVramRequired), + $"{rebuilt.SlotRamRequired}/{rebuilt.SlotVramRequired}/{rebuilt.OtherRamRequired}/{rebuilt.OtherVramRequired}" + + $" against {shipped.SlotRamRequired}/{shipped.SlotVramRequired}/{shipped.OtherRamRequired}/{shipped.OtherVramRequired}"); + static (uint, uint, uint, uint) Figures(ResourceEntry e) => + (e.SlotRamRequired, e.SlotVramRequired, e.OtherRamRequired, e.OtherVramRequired); + string Describe(SdsArchive a, ResourceEntry e) => + $"{a.ResourceTypes[e.TypeId].Name} {e.Data?.Length} bytes {Figures(e)}"; + Check($"{label}: resource for resource", + rebuilt.Entries.Select(Figures).OrderBy(f => f).SequenceEqual(shipped.Entries.Select(Figures).OrderBy(f => f)), + string.Join("; ", rebuilt.Entries.Select(e => Describe(rebuilt, e)).Except(shipped.Entries.Select(e => Describe(shipped, e))).Take(6)) + + " | shipped: " + string.Join("; ", shipped.Entries.Select(e => Describe(shipped, e)).Except(rebuilt.Entries.Select(e => Describe(rebuilt, e))).Take(6))); + + // They survive the round trip through the working copy's side file, and the header of the saved archive. + stated.Save(unpacked); + SdsMemoryRequirements? kept = SdsMemoryRequirements.Load(unpacked); + string saved = Path.Combine(scratch, label + "_rebuilt.sds"); + using (FileStream output = File.Create(saved)) + { + SdsArchive.Pack(unpacked, GameProfile.MafiaII, kept).Save(output, new SdsWriteOptions()); + } + SdsArchive reopened = SdsArchive.Open(saved); + Check($"{label}: and says so again when saved and reopened", kept is { } again && again.Count == stated.Count + && (reopened.SlotRamRequired, reopened.OtherRamRequired) == (shipped.SlotRamRequired, shipped.OtherRamRequired) + && reopened.Entries.Select(Figures).OrderBy(f => f).SequenceEqual(shipped.Entries.Select(Figures).OrderBy(f => f))); + } + // The clone asks for what its source does: the resources are the same but for a longer name. + { + SdsMemoryRequirements stated = SdsMemoryRequirements.FromArchive(Path.Combine(sds, "cars", Source + ".sds")); + SdsArchive shipped = SdsArchive.Open(Path.Combine(sds, "cars", Source + ".sds")); + SdsArchive clone = SdsArchive.Pack(cars[0].To, GameProfile.MafiaII, stated); + Check("the clone, built with its source's requirements, asks for no less than the source", + clone.SlotRamRequired >= shipped.SlotRamRequired && clone.OtherRamRequired >= shipped.OtherRamRequired + && clone.SlotVramRequired >= shipped.SlotVramRequired, + $"slot RAM {clone.SlotRamRequired} against {shipped.SlotRamRequired}, other {clone.OtherRamRequired} against {shipped.OtherRamRequired}"); + } // A Build prunes manifest entries whose file is gone; an XML resource sits on disk as name + ".xml", // and every one of them used to be pruned that way. foreach (string folder in new[] { tables, ingame, cars[0].To }.Concat(text)) diff --git a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs new file mode 100644 index 0000000..9501960 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs @@ -0,0 +1,206 @@ +using System.IO; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json.Nodes; +using Illusion.Assets; +using Illusion.Assets.Cars; +using Illusion.Assets.Sds; +using Illusion.Formats; +using Illusion.Formats.Archive; +using Illusion.Formats.Hashing; +using Illusion.Formats.ResourceFormats; + +namespace Illusion.Diagnostics.Probes; + +/// +/// A car exported as a multiplayer resource, from a clone made and packed on scratch copies (nothing of the +/// game is written): the folder's manifest and car list, the archives it ships, a second car joining the list, +/// and what an export refuses. +/// +internal static class CarM2oExportProbes +{ + private const string Source = "shubert_38"; + private const string Name = "Shubert_38_Export"; + private const string Title = "Shubert 38 Export"; + + // Output: %TEMP%\illusion_car_m2o.txt + internal static void RunCarM2oExportProbe() + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_m2o.txt"); + string scratch = Path.Combine(Path.GetTempPath(), "illusion_car_m2o"); + 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)}"); + } + + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); + + string sds = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + var stock = new FileInfo(Path.Combine(sds, "cars", Source + ".sds")); + var stockWinter = new FileInfo(Path.Combine(sds, "cars", Source + "_z.sds")); + + // A clone on scratch copies: the archives as shipped, the tables as the working copies have them. + string tables = Copy(SdsMeshLoader.EnsureExtracted(new FileInfo(Path.Combine(sds, "tables", "tables.sds"))), + Path.Combine(scratch, "tables")); + string ingame = Copy(SdsMeshLoader.EnsureExtracted(new FileInfo(Path.Combine(sds, "tables", "ingame.sds"))), + Path.Combine(scratch, "ingame")); + var cars = new List<(string From, string To)>(); + var memory = new List(); + foreach (FileInfo archive in new[] { stock, stockWinter }) + { + if (!archive.Exists) continue; + string from = Path.Combine(scratch, "stock_" + Path.GetFileNameWithoutExtension(archive.Name)); + SdsArchive.Open(archive.FullName).Extract(from); + memory.Add(SdsMemoryRequirements.FromArchive(archive.FullName)); + memory[^1].Save(from); + cars.Add((from, Path.Combine(scratch, "clone_" + Path.GetFileNameWithoutExtension(archive.Name)))); + } + string text = Path.Combine(scratch, "text", "tables"); + Directory.CreateDirectory(text); + File.WriteAllText(Path.Combine(text, "TextDatabase.dat"), "", new UTF8Encoding(true)); + var folders = new CarCloneFolders(tables, ingame, cars, [Path.Combine(scratch, "text")]); + CarCloneResult? clone = CarCloner.CloneExtracted(folders, Source, Name, traffic: false, Title, out string? refused); + Check("a clone to export", clone != null, refused ?? ""); + if (clone == null) return; + + string built = Path.Combine(scratch, "built"); + Directory.CreateDirectory(built); + var archives = new List(); + for (int i = 0; i < cars.Count; i++) + { + Check($"{Path.GetFileName(cars[i].To)}: the clone's working copy carries the source's memory requirements", + SdsMemoryRequirements.Load(cars[i].To) is { Count: > 0 }); + string path = Path.Combine(built, Name.ToLowerInvariant() + (i == 0 ? "" : "_z") + ".sds"); + Pack(cars[i].To, path, SdsMemoryRequirements.Load(cars[i].To)); + archives.Add(new FileInfo(path)); + } + + var sources = new CarM2oExportSources( + archives[0], + archives.Count > 1 ? archives[1] : null, + Path.Combine(tables, "tables", "vehicles.tbl"), + Path.Combine(ingame, "tables", "PaintCombinations.tbl"), + [("en", Path.Combine(text, "TextDatabase.dat"))], + CarCloner.SourceOf(cars[0].To), + stock); + string output = Path.Combine(scratch, "export"); + + Check("refuses a resource name the multiplayer would not take", + CarM2oExport.ExportFrom(sources, output, "Bad Name", out string? badName) == null && badName != null, badName ?? ""); + string occupied = Path.Combine(scratch, "occupied"); + Directory.CreateDirectory(occupied); + File.WriteAllText(Path.Combine(occupied, "server.json"), "{}"); + Check("refuses a folder that holds something else", + CarM2oExport.ExportFrom(sources, occupied, "car-test", out string? busy) == null && busy != null + && Directory.GetFileSystemEntries(occupied).Length == 1, busy ?? ""); + // The stock archive under another file name: nothing inside it is filed under that name. + var misnamed = new FileInfo(Path.Combine(built, "not_this_car.sds")); + File.Copy(stock.FullName, misnamed.FullName); + Check("refuses an archive that is not filed under its own name", + CarM2oExport.ExportFrom(sources with { Archive = misnamed, WinterArchive = null }, output, "car-test", out string? alien) == null + && alien != null && !Directory.Exists(output), alien ?? ""); + + CarM2oExportResult? result = CarM2oExport.ExportFrom(sources, output, CarM2oExport.DefaultResource(Name), out refused); + Check("exports the clone", result != null, refused ?? ""); + if (result == null) return; + foreach (string note in result.Notes) sb.AppendLine(" note: " + note); + Check("under the model's name, title and source car", + result.Model == Name && result.Title == Title && result.BasedOn == "Shubert_38" && result.Vehicles == 1, + $"{result.Model} / {result.Title} / {result.BasedOn}"); + Check("no note of shared buffers or packer figures", result.Notes.Count == 0, string.Join(" | ", result.Notes)); + + JsonNode package = JsonNode.Parse(File.ReadAllText(Path.Combine(output, CarM2oExport.PackageFile)))!; + var shipped = (package["mafiahub"]?["files"] as JsonArray)?.Select(f => f!.GetValue()).ToList() ?? []; + Check("package.json names the resource and ships the cars and their list", + package["name"]?.GetValue() == "car-shubert-38-export" && package["version"] != null + && shipped.Contains("cars/**") && shipped.Contains(CarM2oExport.VehiclesFile), string.Join(", ", shipped)); + + JsonNode document = JsonNode.Parse(File.ReadAllText(Path.Combine(output, CarM2oExport.VehiclesFile)))!; + JsonNode? entry = (document["vehicles"] as JsonArray)?.FirstOrDefault(); + Check("vehicles.json lists the car", document["format"]?.GetValue() == CarM2oExport.Format && entry != null + && entry["model"]?.GetValue() == Name && entry["title"]?.GetValue() == Title + && entry["titles"]?["en"]?.GetValue() == Title && entry["basedOn"]?.GetValue() == "Shubert_38"); + if (entry == null) return; + Check("with the hashes its archive is keyed by", + entry["hashes"]?["model"]?.GetValue() == "0x" + Fnv64.Hash(Name).ToString("x16") + && entry["hashes"]?["entityData"]?.GetValue() == "0x" + Fnv64.Hash(Name.ToLowerInvariant()).ToString("x16")); + GameTable vehicles = GameTable.Load(Path.Combine(tables, "tables", "vehicles.tbl")); + var values = entry["vehicleTable"]?["values"] as JsonArray; + Check("with its vehicle-table row, cell for cell", + entry["vehicleTable"]?["id"]?.GetValue() == clone.VehicleId && values != null + && values.Count == vehicles.ColumnCount && (entry["vehicleTable"]?["columns"] as JsonArray)?.Count == vehicles.ColumnCount + && values[0]!.GetValue() == clone.VehicleId && values[2]!.GetValue() == Name, + $"{values?.Count} cells"); + Check("with its paint combinations", (entry["paintCombinations"]?["values"] as JsonArray)?.Count > 0); + + var files = entry["files"] as JsonArray; + bool intact = files != null && files.Count == archives.Count; + for (int i = 0; intact && i < files!.Count; i++) + { + string shippedFile = Path.Combine(output, files[i]!["path"]!.GetValue()); + intact = File.Exists(shippedFile) + && files[i]!["size"]!.GetValue() == archives[i].Length + && files[i]!["sha256"]!.GetValue() == Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(archives[i].FullName))); + } + Check("the archives are in cars/ as they were built, sized and hashed", intact + && entry["archive"]?.GetValue() == "cars/" + archives[0].Name + && (archives.Count < 2 || entry["winterArchive"]?.GetValue() == "cars/" + archives[1].Name)); + Check("nothing else is in the folder", + Directory.GetFiles(output, "*", SearchOption.AllDirectories).Length == archives.Count + 2); + + // A clone that kept the source's buffer names, and an archive packed with no requirements, are said so. + string shared = Copy(cars[0].From, Path.Combine(scratch, "shared")); + string sharedArchive = Path.Combine(built, Source + ".sds"); + Pack(shared, sharedArchive, memory: null); + CarM2oExportResult? plain = CarM2oExport.ExportFrom( + sources with { Archive = new FileInfo(sharedArchive), WinterArchive = null, BasedOn = "Shubert_38" }, + output, "car-test", out refused); + Check("a second car joins the list", plain is { Vehicles: 2, Model: "Shubert_38" }, refused ?? ""); + Check("an archive packed without requirements is noted", + plain != null && plain.Notes.Any(n => n.Contains("memory", StringComparison.Ordinal)), string.Join(" | ", plain?.Notes ?? [])); + Check("buffers shared with the source car are noted", + plain != null && plain.Notes.Any(n => n.Contains("buffers", StringComparison.Ordinal))); + Check("the package.json already there keeps its name", + JsonNode.Parse(File.ReadAllText(Path.Combine(output, CarM2oExport.PackageFile)))!["name"]?.GetValue() == "car-shubert-38-export"); + + CarM2oExportResult? again = CarM2oExport.ExportFrom(sources, output, "car-test", out refused); + Check("exporting a car again replaces its entry", again is { Vehicles: 2 }, refused ?? ""); + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + try { if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); } + catch (IOException) { /* scratch left behind */ } + sb.Insert(0, $"CAR M2O EXPORT PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static void Pack(string folder, string path, SdsMemoryRequirements? memory) + { + SdsArchive archive = SdsArchive.Pack(folder, GameProfile.MafiaII, memory); + using FileStream output = File.Create(path); + archive.Save(output, new SdsWriteOptions()); + } + + private static string Copy(string from, string to) + { + foreach (string file in Directory.EnumerateFiles(from, "*", SearchOption.AllDirectories)) + { + string target = Path.Combine(to, Path.GetRelativePath(from, file)); + Directory.CreateDirectory(Path.GetDirectoryName(target)!); + File.Copy(file, target); + } + return to; + } +} diff --git a/src/Illusion/Diagnostics/Probes/McpProbes.cs b/src/Illusion/Diagnostics/Probes/McpProbes.cs index 7b5d489..5ce853a 100644 --- a/src/Illusion/Diagnostics/Probes/McpProbes.cs +++ b/src/Illusion/Diagnostics/Probes/McpProbes.cs @@ -67,6 +67,7 @@ internal static class McpProbes "car_tuning_set", "car_clone", "archive_build", + "car_export_m2o", }; /// Records one assertion. A delegate rather than an Action so the optional diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 0727f7c..9c0209f 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -788,7 +788,7 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) return null; } - public string? BuildArchive(string archive, out PackedArchive? result) + public string? BuildArchive(string archive, string? memoryFrom, out PackedArchive? result) { result = null; if (EnsureEnvironment() is { } notReady) return notReady; @@ -802,6 +802,12 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) } try { + if (!string.IsNullOrWhiteSpace(memoryFrom)) + { + var reference = new FileInfo(memoryFrom); + if (!Path.IsPathRooted(memoryFrom) || !reference.Exists) return $"no such archive to take memory requirements from: {memoryFrom}"; + Assets.Sds.SdsWriter.AdoptMemoryRequirements(sds, reference); + } Assets.Sds.SdsWriter.PackResult packed = Assets.Sds.SdsWriter.PackSds(sds, createBackup: true); result = new PackedArchive(packed.Archive, packed.Backup); return null; @@ -812,6 +818,29 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) } } + public string? ExportCarForM2o(string car, string? output, string? resource, out M2oExportInfo? result) + { + result = null; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(car)) return "name the car to export"; + if (!string.IsNullOrWhiteSpace(output) && !Path.IsPathRooted(output)) return "give the output folder's full path"; + try + { + if (Assets.Cars.CarM2oExport.Export(car, string.IsNullOrWhiteSpace(output) ? null : output, + string.IsNullOrWhiteSpace(resource) ? null : resource, out string? refusal) is not { } exported) + { + return refusal; + } + result = new M2oExportInfo(exported.Folder, exported.Resource, exported.Model, exported.Title, exported.BasedOn, + exported.Vehicles, exported.Files, exported.Notes); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Formats.FileFormatException) + { + return "could not export the car: " + ex.Message; + } + } + public string? CloneCar(string source, string name, bool traffic, string? title, out CarCloneInfo? result) { result = null; From ed6863fdb50d5f8300e2f8407460be644e183681 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:01:24 +0300 Subject: [PATCH 033/102] feat(cars): build a car under another car's name car_substitute replaces .sds with the source car's model keyed by the target's model name (root frame, name table, prefab entry, entity data, buffers), keeps a backup and touches no table - for trying a car where models are spawned from a fixed list of names. --- .../Cars/CarCloner.Substituting.cs | 107 ++++++++++++++++++ .../Cars/CarSubstituteOutcome.cs | 10 ++ src/Illusion.Mcp/EditorModels.cs | 4 + src/Illusion.Mcp/IEditorSession.cs | 4 + src/Illusion.Mcp/Tools/EditorTools.cs | 20 ++++ .../Diagnostics/Probes/CarCloneProbes.cs | 25 ++++ src/Illusion/Diagnostics/Probes/McpProbes.cs | 1 + src/Illusion/Mcp/AppEditorSession.cs | 18 +++ 8 files changed, 189 insertions(+) create mode 100644 src/Illusion.Assets/Cars/CarCloner.Substituting.cs create mode 100644 src/Illusion.Assets/Cars/CarSubstituteOutcome.cs diff --git a/src/Illusion.Assets/Cars/CarCloner.Substituting.cs b/src/Illusion.Assets/Cars/CarCloner.Substituting.cs new file mode 100644 index 0000000..d0e283d --- /dev/null +++ b/src/Illusion.Assets/Cars/CarCloner.Substituting.cs @@ -0,0 +1,107 @@ +using Illusion.Assets.Sds; +using Illusion.Formats.ResourceFormats; + +namespace Illusion.Assets.Cars; + +public static partial class CarCloner +{ + /// + /// Builds one car under ANOTHER car's name: the target's archive is replaced by the source's model, keyed + /// throughout by the target's model name. No table is touched — the game goes on listing the target as it + /// did and finds the source's shape, tuning and sounds in its archive. + /// + /// + /// This is how a car is tried where nothing can be registered — a multiplayer that spawns from a fixed list + /// of names. A backup is kept of every archive replaced; restoring it (and dropping the working copy) puts + /// the target back. The target's working copy is replaced as well, with whatever was not built from it. + /// A winter twin is replaced only where both cars have one. + /// + /// + public static CarSubstituteOutcome? Substitute(string source, string target, out string? refusal) + { + ArgumentNullException.ThrowIfNull(source); + ArgumentNullException.ThrowIfNull(target); + if (!MafiaEnvironment.IsInitialized) + { + refusal = "the game folder is not set"; + return null; + } + + string sds = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + string sourceStem = Path.GetFileNameWithoutExtension(source).ToLowerInvariant(); + string targetStem = Path.GetFileNameWithoutExtension(target).ToLowerInvariant(); + if (sourceStem == targetStem) + { + refusal = "the car and the one it replaces are the same"; + return null; + } + var tablesSds = new FileInfo(Path.Combine(sds, "tables", "tables.sds")); + if (!tablesSds.Exists) + { + refusal = "the game's tables.sds is not where it belongs (pc\\sds\\tables)"; + return null; + } + GameTable vehicles = GameTable.Load(Path.Combine(SdsMeshLoader.EnsureExtracted(tablesSds), "tables", "vehicles.tbl")); + int sourceRow = vehicles.FindRow(VehicleNameColumn, sourceStem); + int targetRow = vehicles.FindRow(VehicleNameColumn, targetStem); + if (sourceRow < 0 || targetRow < 0) + { + refusal = $"vehicles.tbl has no car named '{(sourceRow < 0 ? sourceStem : targetStem)}'"; + return null; + } + string sourceModel = (string)vehicles.Cell(sourceRow, VehicleNameColumn); + string targetModel = (string)vehicles.Cell(targetRow, VehicleNameColumn); + + var notes = new List(); + var cars = new List<(FileInfo From, FileInfo To)>(); + foreach (string suffix in new[] { "", "_z" }) + { + var from = new FileInfo(Path.Combine(sds, "cars", sourceStem + suffix + ".sds")); + var to = new FileInfo(Path.Combine(sds, "cars", targetStem + suffix + ".sds")); + if (suffix.Length == 0 && (!from.Exists || !to.Exists)) + { + refusal = $"there is no car archive {(from.Exists ? to.Name : from.Name)} in pc\\sds\\cars"; + return null; + } + if (!to.Exists) + { + if (from.Exists) notes.Add($"{targetModel} has no winter archive — the summer one stands in for it"); + continue; + } + if (!from.Exists) + { + notes.Add($"{sourceModel} has no winter archive — {to.Name} is left as it was"); + continue; + } + cars.Add((from, to)); + } + + DateTime when = DateTime.Now; + var packed = new List<(string Archive, string? Backup)>(); + foreach ((FileInfo from, FileInfo to) in cars) + { + string fromFolder = SdsMeshLoader.EnsureExtracted(from); + SdsWriter.EnsureMemoryRequirements(from); + string toFolder = MafiaEnvironment.ExtractedDir(to); + SdsWriter.DeleteExtracted(toFolder); + notes.AddRange(SubstituteExtracted(fromFolder, toFolder, sourceModel, targetModel)); + SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: true, when); + packed.Add((made.Archive, made.Backup)); + } + notes.Add(GameFileIndex.Reset() + ? "the game's file list (vfs.bin) was reset — the next start rebuilds it" + : $"the game's file list was not reset — remove {GameFileIndex.Path} before starting the game"); + refusal = null; + return new CarSubstituteOutcome(targetModel, packed, notes); + } + + /// + /// The substitution itself, on working copies: copies the source car's folder to + /// (which must not exist) and files everything in it under . Nothing is packed. + /// + public static List SubstituteExtracted(string from, string to, string sourceModel, string targetModel) + { + CopyTree(from, to); + return RenameInside(to, sourceModel, targetModel); + } +} diff --git a/src/Illusion.Assets/Cars/CarSubstituteOutcome.cs b/src/Illusion.Assets/Cars/CarSubstituteOutcome.cs new file mode 100644 index 0000000..6c09c6a --- /dev/null +++ b/src/Illusion.Assets/Cars/CarSubstituteOutcome.cs @@ -0,0 +1,10 @@ +namespace Illusion.Assets.Cars; + +/// +/// What building one car under another's name wrote: the model name the archives are now keyed by, each archive +/// replaced with the backup taken of it, and what was left as it was and why. +/// +public sealed record CarSubstituteOutcome( + string Model, + IReadOnlyList<(string Archive, string? Backup)> Packed, + IReadOnlyList Notes); diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index fe2b2f2..291ae11 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -41,6 +41,10 @@ public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, i /// One archive a car clone wrote, and the backup of what it replaced. public sealed record PackedArchive(string Archive, string? Backup); +/// One car built under another's name: the model name the replaced archives are keyed by, each archive +/// with the backup of what it held, and what was left as it was. +public sealed record CarSubstituteInfo(string Model, IReadOnlyList Packed, IReadOnlyList Notes); + /// A car exported as a multiplayer resource: the folder and the resource's name, the model, its title /// and the car it was cloned from, how many cars the folder lists now, the files written and what the one /// shipping it should know. diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index 6688013..2e5c2c9 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -145,6 +145,10 @@ public interface IEditorSession /// (a clone made before they were kept takes them from the car it was cloned from). Null on success. string? BuildArchive(string archive, string? memoryFrom, out PackedArchive? result); + /// Builds one car under another car's name, replacing that car's archive (backup kept) and touching + /// no table. Null on success. + string? SubstituteCar(string source, string target, out CarSubstituteInfo? result); + /// Exports a built car as a multiplayer resource folder (package.json, cars/, vehicles.json); /// nothing of the game is written. Null on success. string? ExportCarForM2o(string car, string? output, string? resource, out M2oExportInfo? result); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 9d6c800..faf17a9 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -204,6 +204,26 @@ public static async Task ArchiveBuild( } } + [McpServerTool(Name = "car_substitute")] + [Description("Build one car under ANOTHER car's name: pc\\sds\\cars\\.sds is replaced by the source car's model, with the root frame, name table, prefab entry, entity data and buffers keyed by the target's model name. No table is touched — the game lists the target as before and finds the source's shape and tuning in its archive. For trying a car where nothing can be registered (a multiplayer that spawns from a fixed list of names). A timestamped backup of each replaced archive is kept in cars\\backups; the winter _z twin is replaced only where both cars have one. This OVERWRITES game files and the target's working copy: the game must not be running.")] + public static async Task CarSubstitute( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The car whose model is used, by archive or model name, e.g. 'shubert_38_custom'.")] string source, + [Description("The car whose archive is replaced, e.g. 'shubert_38_destr'.")] string target) + { + try + { + CarSubstituteInfo? result = null; + string? refused = await ui.RunAsync(() => editor.SubstituteCar(source, target, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, substitute = result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + [McpServerTool(Name = "car_export_m2o")] [Description("Export a built car as a Mafia II Online resource folder: package.json (mafiahub.files ships cars/** and vehicles.json), cars/.sds with its winter _z twin, and vehicles.json — model name, title per language, the car it was cloned from, the hashes the archive is keyed by, its vehicles.tbl and PaintCombinations rows, size and sha256 of each archive. Takes pc\\sds\\cars\\.sds as it stands (build first) and refuses an archive that is not filed under its own name throughout. A second export into the same folder adds to the list. Writes only the output folder — nothing of the game or of the multiplayer. The multiplayer cannot load a vehicle model from a resource yet: the folder is what its developer is asked to support.")] public static async Task CarExportM2o( diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index aafaec1..7d0a90c 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -186,6 +186,31 @@ void Check(string name, bool ok, string detail = "") } Check("the toolkit reads the clone as a car", Car.ReadFrom(cars[0].To) != null); + // The clone built under another car's name: everything the clone's name keyed is the other name's now. + { + const string Other = "Shubert_38_destr"; + string substitute = Path.Combine(scratch, "substitute"); + foreach (string note in CarCloner.SubstituteExtracted(cars[0].To, substitute, Name, Other)) sb.AppendLine(" note: " + note); + ExtractedSds swapped = ExtractedSds.Load(substitute); + ExtractedSds clone = ExtractedSds.Load(cars[0].To); + var names = swapped.FrameResource!.FrameObjects.Values.OfType().Select(f => f.Name.String).ToList(); + Check("substitute: the root frame carries the other car's name", names.Contains(Other) && !names.Contains(Name)); + Check("substitute: the name table lists it first", + swapped.FrameNameTable?.FrameData is { Length: > 0 } listed && listed[0].Name == Other); + PrefabFile prefab = PrefabFile.Load(swapped.Manifest.GetFiles("PREFAB")[0]); + Check("substitute: the prefab entry follows it", prefab.Contains(Fnv64.Hash(Other)) && !prefab.Contains(Fnv64.Hash(Name))); + Check("substitute: the entity data is filed under it in lower case", + EntityDataStorageFile.Load(swapped.Manifest.GetFiles("EntityDataStorage")[0]).Hash == Fnv64.Hash(Other.ToLowerInvariant())); + var cloneBuffers = new HashSet(clone.VertexBuffers.Buffers.Keys.Concat(clone.IndexBuffers.Buffers.Keys)); + var lods = swapped.FrameResource.FrameGeometries.Values.SelectMany(g => g.LOD ?? []).ToList(); + Check("substitute: the buffers are named for it and every level of detail finds them", + !swapped.VertexBuffers.Buffers.Keys.Concat(swapped.IndexBuffers.Buffers.Keys).Any(cloneBuffers.Contains) + && lods.Count > 0 && lods.All(l => swapped.VertexBuffers.GetBuffer(l.VertexBufferRef.Hash) != null + && swapped.IndexBuffers.GetBuffer(l.IndexBufferRef.Hash) != null) + && swapped.VertexBuffers.GetBuffer(Fnv64.Hash(Other + ".Root.L0.VB0")) != null); + Check("substitute: packs and reads as a car", Packs(substitute) && Car.ReadFrom(substitute) != null); + } + // A rebuilt name table has to be the table the archive shipped with — order included. int cars_ = 0, same = 0; var differing = new List(); diff --git a/src/Illusion/Diagnostics/Probes/McpProbes.cs b/src/Illusion/Diagnostics/Probes/McpProbes.cs index 5ce853a..ab2e5dd 100644 --- a/src/Illusion/Diagnostics/Probes/McpProbes.cs +++ b/src/Illusion/Diagnostics/Probes/McpProbes.cs @@ -68,6 +68,7 @@ internal static class McpProbes "car_clone", "archive_build", "car_export_m2o", + "car_substitute", }; /// Records one assertion. A delegate rather than an Action so the optional diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 9c0209f..b1afcf4 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -818,6 +818,24 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) } } + public string? SubstituteCar(string source, string target, out CarSubstituteInfo? result) + { + result = null; + if (EnsureEnvironment() is { } notReady) return notReady; + if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target)) return "name the car and the one it replaces"; + try + { + if (Assets.Cars.CarCloner.Substitute(source, target, out string? refusal) is not { } outcome) return refusal; + result = new CarSubstituteInfo(outcome.Model, + [.. outcome.Packed.Select(p => new PackedArchive(p.Archive, p.Backup))], outcome.Notes); + return null; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Formats.FileFormatException) + { + return "could not write the archives (is the game running?): " + ex.Message; + } + } + public string? ExportCarForM2o(string car, string? output, string? resource, out M2oExportInfo? result) { result = null; From 34bf76557ea8a26b31b76c0771bc01ea46a2ebb2 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 16:44:58 +0300 Subject: [PATCH 034/102] feat(cars): the M2O car export follows the map export's layout; docs Archives sit under sds/cars/ in the exported folder and each entry names the path the game loads it from (sds, winterSds), the way the map export names its patch targets. README: the car tools, memory requirements, the tool count. --- README.md | 29 +++++++++++++++++-- src/Illusion.Assets/Cars/CarM2oExport.cs | 13 +++++---- src/Illusion.Mcp/IEditorSession.cs | 2 +- src/Illusion.Mcp/Tools/EditorTools.cs | 2 +- .../Diagnostics/Probes/CarM2oExportProbes.cs | 11 ++++--- 5 files changed, 43 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index aad6d12..fb73f7b 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,14 @@ queues the winter archive for Build. The other ten (eastside, greenfield, hunter midtown, port, sandisland, seagift, southport, westside) have a winter scene of their own and are refused - edit that archive directly. `--probe-season-mirror` covers both. +**Memory requirements.** Every resource in an archive states how much memory the engine should +budget for it, and that is not the payload size: a shipped car asks for 170 232 bytes of slot RAM +where its payloads add up to 148 079. Extraction does not keep those figures, so they are read from +the archive as it shipped (its oldest backup, or the archive itself before its first Build), kept +beside the working copy in `illusion_memory.json`, and stated again on every Build - scaled when a +resource changed size. A repacked stock car states exactly what the original did +(`--probe-car-clone`). + ### MCP server An MCP endpoint runs for the lifetime of the application at `http://127.0.0.1:2010/mcp` - loopback @@ -232,7 +240,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 66 tools. The file tools all read through the same format layer the editor uses, so what +It serves 76 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. @@ -250,6 +258,7 @@ map editor itself. | **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` | | **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` | | **Blender session** | `blender_open`, `blender_push`, `blender_end` | | **Viewport** | `camera_get`, `camera_set`, `camera_look_at`, `camera_frame_selection`, `view_set`, `viewport_screenshot` | @@ -260,8 +269,22 @@ and nothing reaches the game before `editor_build` (which keeps the usual timest `viewport_screenshot` is how a client checks what a push actually looks like. `editor_target resource` points the same tools at the resource editor's stage (one archive, such as a car, opened with `resource_open`); `car_tuning` and `car_tuning_set` read and edit that car's -entity-data tables the way the Tuning tab does; \car_clone\ makes a new car out of an existing one -(a renamed copy of its archive, registered in the vehicle, paint, cover-point and traffic tables). +entity-data tables the way the Tuning tab does. + +**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 +traffic tables, with a title of its own in every installed language. `car_substitute` builds a car +under ANOTHER car's name instead - that car's archive is replaced (backup kept) and no table is +touched - which is how a car is tried where nothing can be registered, such as a multiplayer that +spawns from a fixed list of names. `car_export_m2o` writes a built car out as an M2O resource +folder: `package.json`, the archives under `sds/cars/` and `vehicles.json`, which names for each car +the path the game loads it from, the model name its archive is keyed by, its title per language, +the car it was cloned from, its `vehicles.tbl` and `PaintCombinations.tbl` rows, and the size and +SHA-256 of each file; only the archives travel, no table edit does. It refuses an archive that is +not filed under its own name throughout, and says so when the buffers still bear the source car's +names. `archive_build` packs one archive's working copy with the usual backup, for an edit made in +the working copy itself. `--probe-car-clone` and `--probe-car-m2o` cover them on scratch copies. Two of the file tools are worth knowing about before you rely on them. `edit_stream_map` is the only one that writes: it previews by default (`dryRun` is true unless you say otherwise), keeps a diff --git a/src/Illusion.Assets/Cars/CarM2oExport.cs b/src/Illusion.Assets/Cars/CarM2oExport.cs index 999d46b..a2c1bdc 100644 --- a/src/Illusion.Assets/Cars/CarM2oExport.cs +++ b/src/Illusion.Assets/Cars/CarM2oExport.cs @@ -17,8 +17,9 @@ namespace Illusion.Assets.Cars; /// /// A car as a resource a Mafia II Online server can ship: a folder with the resource's package.json, the -/// car's archive under cars/ (and its winter twin) and vehicles.json, which says what the -/// archives hold. +/// car's archive (and its winter twin) and vehicles.json, which says what the archives hold. An archive +/// sits in the folder under the path the game loads it from — sds/cars/<name>.sds — the way the map +/// export lays out its patches, and its entry names that target (/sds/cars/<name>.sds) beside the file. /// /// /// Only what can travel goes in. The single-player registration — the row in vehicles.tbl, the paint @@ -43,7 +44,7 @@ public static partial class CarM2oExport /// The version of 's layout. public const int Format = 1; - private const string CarsFolder = "cars"; + private const string CarsFolder = "sds/cars"; private const int VehicleIdColumn = 0; private const int VehicleNameColumn = 2; private const int VehicleTextColumn = 3; @@ -179,7 +180,9 @@ public static string DefaultResource(string car) => var entry = new JsonObject { ["model"] = model, + ["sds"] = $"/{CarsFolder}/{sources.Archive.Name.ToLowerInvariant()}", ["archive"] = $"{CarsFolder}/{sources.Archive.Name}", + ["winterSds"] = sources.WinterArchive != null ? JsonValue.Create($"/{CarsFolder}/{sources.WinterArchive.Name.ToLowerInvariant()}") : null, ["winterArchive"] = sources.WinterArchive != null ? JsonValue.Create($"{CarsFolder}/{sources.WinterArchive.Name}") : null, }; @@ -220,7 +223,7 @@ public static string DefaultResource(string car) => } // Everything is known; now the folder. - string cars = Path.Combine(output, CarsFolder); + string cars = Path.Combine(output, "sds", "cars"); Directory.CreateDirectory(cars); var written = new List(); var files = new JsonArray(); @@ -334,7 +337,7 @@ private static void WritePackage(string packageFile, string resource, string mod }; if (package["mafiahub"] is not JsonObject hub) package["mafiahub"] = hub = new JsonObject { ["priority"] = 0 }; if (hub["files"] is not JsonArray shipped) hub["files"] = shipped = []; - foreach (string pattern in new[] { $"{CarsFolder}/**", VehiclesFile }) + foreach (string pattern in new[] { "sds/**", VehiclesFile }) { if (!shipped.Any(f => f?.GetValueKind() == JsonValueKind.String && f.GetValue() == pattern)) shipped.Add(pattern); } diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index 2e5c2c9..96da26d 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -149,7 +149,7 @@ public interface IEditorSession /// no table. Null on success. string? SubstituteCar(string source, string target, out CarSubstituteInfo? result); - /// Exports a built car as a multiplayer resource folder (package.json, cars/, vehicles.json); + /// Exports a built car as a multiplayer resource folder (package.json, sds/cars/, vehicles.json); /// nothing of the game is written. Null on success. string? ExportCarForM2o(string car, string? output, string? resource, out M2oExportInfo? result); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index faf17a9..eae7e13 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -225,7 +225,7 @@ public static async Task CarSubstitute( } [McpServerTool(Name = "car_export_m2o")] - [Description("Export a built car as a Mafia II Online resource folder: package.json (mafiahub.files ships cars/** and vehicles.json), cars/.sds with its winter _z twin, and vehicles.json — model name, title per language, the car it was cloned from, the hashes the archive is keyed by, its vehicles.tbl and PaintCombinations rows, size and sha256 of each archive. Takes pc\\sds\\cars\\.sds as it stands (build first) and refuses an archive that is not filed under its own name throughout. A second export into the same folder adds to the list. Writes only the output folder — nothing of the game or of the multiplayer. The multiplayer cannot load a vehicle model from a resource yet: the folder is what its developer is asked to support.")] + [Description("Export a built car as a Mafia II Online resource folder: package.json (mafiahub.files ships sds/** and vehicles.json), sds/cars/.sds with its winter _z twin, and vehicles.json — the path the game loads each archive from, model name, title per language, the car it was cloned from, the hashes the archive is keyed by, its vehicles.tbl and PaintCombinations rows, size and sha256 of each archive. Takes pc\\sds\\cars\\.sds as it stands (build first) and refuses an archive that is not filed under its own name throughout. A second export into the same folder adds to the list. Writes only the output folder — nothing of the game or of the multiplayer. The multiplayer cannot load a vehicle model from a resource yet: the folder is what its developer is asked to support.")] public static async Task CarExportM2o( IEditorSession editor, IUiThreadMarshal ui, diff --git a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs index 9501960..4f68e4c 100644 --- a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs @@ -119,7 +119,7 @@ void Check(string name, bool ok, string detail = "") var shipped = (package["mafiahub"]?["files"] as JsonArray)?.Select(f => f!.GetValue()).ToList() ?? []; Check("package.json names the resource and ships the cars and their list", package["name"]?.GetValue() == "car-shubert-38-export" && package["version"] != null - && shipped.Contains("cars/**") && shipped.Contains(CarM2oExport.VehiclesFile), string.Join(", ", shipped)); + && shipped.Contains("sds/**") && shipped.Contains(CarM2oExport.VehiclesFile), string.Join(", ", shipped)); JsonNode document = JsonNode.Parse(File.ReadAllText(Path.Combine(output, CarM2oExport.VehiclesFile)))!; JsonNode? entry = (document["vehicles"] as JsonArray)?.FirstOrDefault(); @@ -148,9 +148,12 @@ void Check(string name, bool ok, string detail = "") && files[i]!["size"]!.GetValue() == archives[i].Length && files[i]!["sha256"]!.GetValue() == Convert.ToHexStringLower(SHA256.HashData(File.ReadAllBytes(archives[i].FullName))); } - Check("the archives are in cars/ as they were built, sized and hashed", intact - && entry["archive"]?.GetValue() == "cars/" + archives[0].Name - && (archives.Count < 2 || entry["winterArchive"]?.GetValue() == "cars/" + archives[1].Name)); + Check("the archives are in sds/cars/ as they were built, sized and hashed", intact + && entry["archive"]?.GetValue() == "sds/cars/" + archives[0].Name + && (archives.Count < 2 || entry["winterArchive"]?.GetValue() == "sds/cars/" + archives[1].Name)); + Check("each named by the path the game loads it from", + entry["sds"]?.GetValue() == "/sds/cars/" + archives[0].Name + && (archives.Count < 2 || entry["winterSds"]?.GetValue() == "/sds/cars/" + archives[1].Name)); Check("nothing else is in the folder", Directory.GetFiles(output, "*", SearchOption.AllDirectories).Length == archives.Count + 2); From 8cdb1b5fdaf7b3dafb4a52cd0ccfe11813f10631 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:28:41 +0300 Subject: [PATCH 035/102] fix(viewport): a mesh without normals is lit instead of drawn black Foliage declares Position, Color, TexCoords0 (65 of the 827 meshes of city_crash): the game lights it by vertex colour and it carries no normals. They reached the surface shader as zeroes, normalize() of that is NaN, every light term after it is NaN and the bush came out black. Such a vertex is now lit as a surface facing the sky. --probe-vertex-normals: which meshes of an archive declare no normals, and a quad without normals rendered and read back in every mode - black before, lit like one facing up now. --- .../Shaders/ShaderCompiler.cs | 7 +- src/Illusion/Diagnostics/ProbeRunner.cs | 5 + .../Diagnostics/Probes/VertexNormalProbes.cs | 159 ++++++++++++++++++ 3 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs diff --git a/src/Illusion.Rendering/Shaders/ShaderCompiler.cs b/src/Illusion.Rendering/Shaders/ShaderCompiler.cs index 71e99d9..83767e8 100644 --- a/src/Illusion.Rendering/Shaders/ShaderCompiler.cs +++ b/src/Illusion.Rendering/Shaders/ShaderCompiler.cs @@ -92,7 +92,12 @@ float4 PSMain(PSIn i) : SV_TARGET // fall back to the geometric normal (the Stage-1 behaviour). Mafia II stores normals as DXT5nm (X in alpha) // — 'nt.x *= nt.w' recovers X and is a no-op for DXT1 (alpha=1); Z is reconstructed from XY. The flat-normal // placeholder decodes to (0,0,1), so a material without a normal map yields exactly the vertex normal. - float3 Ngeom = normalize(i.nrm); + // A mesh may carry no normals at all: foliage declares Position, Color, TexCoords0 and is lit in the game + // by its vertex colour, and its normal channel reaches here as zeroes. normalize() of that is NaN, every + // light term after it is NaN, and saturate(NaN)=0 drew the whole bush black. Such a vertex is lit as a + // surface facing the sky (world is Z-up): full sky ambient plus whatever sun stands above the horizon. + float nl = dot(i.nrm, i.nrm); + float3 Ngeom = nl > 1e-8 ? i.nrm * rsqrt(nl) : float3(0.0, 0.0, 1.0); float3 N = Ngeom; float tl = dot(i.tan, i.tan); float bl = dot(i.bin, i.bin); diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 113f96a..34c5a89 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -185,6 +185,11 @@ public static bool TryRun(string[] args) return true; // Buffers nothing draws from: none in the archives as shipped, how many in the working copies. // Output: %TEMP%\illusion_geometry_sweep.txt + // Which meshes of an archive carry no normals (foliage), and what the renderer is handed for them. + // Output: %TEMP%\illusion_vertex_normals.txt + case "--probe-vertex-normals": + VertexNormalProbes.RunVertexNormalProbe(args.Length >= 2 ? args[1] : @"city_crash\city_crash.sds"); + return true; case "--probe-geometry-sweep": GeometrySweepProbes.RunGeometrySweepProbe(); return true; diff --git a/src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs b/src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs new file mode 100644 index 0000000..3930bad --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs @@ -0,0 +1,159 @@ +using System.IO; +using System.Numerics; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Geometry; +using Illusion.Rendering.Gpu; +using Illusion.Rendering.Passes; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Which meshes of an archive carry no normals, and what the decode hands the renderer for them. A vertex +/// declaration without (foliage, which the game lights by vertex colour) +/// decodes to zero normals, and a zero normal is what a surface shader cannot light. +/// +internal static class VertexNormalProbes +{ + // Output: %TEMP%\illusion_vertex_normals.txt + internal static void RunVertexNormalProbe(string archive) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_vertex_normals.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)}"); + } + + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + var sds = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", archive)); + if (!sds.Exists) { sb.AppendLine("no such archive: " + sds.FullName); return; } + ExtractedSds scene = ExtractedSds.Load(SdsMeshLoader.EnsureExtracted(sds)); + if (scene.FrameResource == null) { sb.AppendLine("no frame resource in " + archive); return; } + + var byDeclaration = new Dictionary>(); + int meshes = 0, bare = 0, bareZero = 0, declaredZero = 0; + var zeroNames = new List(); + foreach (FrameObjectSingleMesh mesh in scene.FrameResource.FrameObjects.Values.OfType()) + { + if (mesh.Geometry?.LOD is not { Length: > 0 } lods) continue; + meshes++; + VertexFlags declaration = lods[0].VertexDeclaration; + if (!byDeclaration.TryGetValue(declaration, out List? names)) byDeclaration[declaration] = names = []; + names.Add(mesh.Name.String ?? "?"); + + var decoded = SdsMeshLoader.DecodeLod(mesh, 0); + if (decoded == null) continue; + bool allZero = decoded.Normals.All(n => n == Vector3.Zero); + if (!declaration.HasFlag(VertexFlags.Normals)) + { + bare++; + if (allZero) bareZero++; + } + else if (allZero) + { + declaredZero++; + zeroNames.Add(mesh.Name.String ?? "?"); + } + } + + sb.AppendLine($"{archive}: {meshes} meshes, {byDeclaration.Count} vertex declarations"); + foreach ((VertexFlags declaration, List names) in byDeclaration.OrderByDescending(p => p.Value.Count)) + { + sb.AppendLine($" {names.Count,5} x {declaration}"); + sb.AppendLine($" e.g. {string.Join(", ", names.Distinct().Take(8))}"); + } + sb.AppendLine(); + Check("meshes were read", meshes > 0, $"{meshes}"); + Check("a mesh that declares no normals decodes to zero normals", bare == bareZero, $"{bareZero} of {bare}"); + Check("a mesh that declares normals has some", declaredZero == 0, + $"{declaredZero}: {string.Join(", ", zeroNames.Take(8))}"); + sb.AppendLine($"meshes the lit shader gets a zero normal for: {bare} of {meshes}"); + sb.AppendLine(); + RenderBareQuad(Check); + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + sb.Insert(0, $"VERTEX NORMAL PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + // The same quad three times under the same camera, in every render mode: not there at all (what the + // background reads as), with its normals facing up, and with none (zeroes, as the decode hands them + // over). The bare one has to come out lit, and lit the same as the one facing the sky. + private static void RenderBareQuad(Action check) + { + foreach (RenderMode mode in Enum.GetValues()) + { + if (mode == RenderMode.Wireframe) continue; // lines, not a surface to read a pixel from + (float r, float g, float b) empty = CentrePixel(mode, null); + (float r, float g, float b) lit = CentrePixel(mode, Vector3.UnitZ); + (float r, float g, float b) bare = CentrePixel(mode, Vector3.Zero); + check($"{mode}: the quad is in the frame", Distance(lit, empty) > 0.05f, + $"background ({empty.r:F2}, {empty.g:F2}, {empty.b:F2}), facing up ({lit.r:F2}, {lit.g:F2}, {lit.b:F2})"); + check($"{mode}: a quad with no normals is lit like one facing the sky", + Distance(bare, lit) < 0.02f && bare.r + bare.g + bare.b > 0.3f, + $"facing up ({lit.r:F2}, {lit.g:F2}, {lit.b:F2}), no normals ({bare.r:F2}, {bare.g:F2}, {bare.b:F2})"); + } + } + + private static float Distance((float r, float g, float b) a, (float r, float g, float b) b) => + MathF.Abs(a.r - b.r) + MathF.Abs(a.g - b.g) + MathF.Abs(a.b - b.b); + + // What the middle of the frame shows with a 2 m quad on the ground under an oblique camera. + private static (float r, float g, float b) CentrePixel(RenderMode mode, Vector3? normal) + { + GpuContext? gpu = null; + SceneRenderer? renderer = null; + SharedRenderTarget? target = null; + try + { + gpu = new GpuContext(); + renderer = new SceneRenderer(gpu) { ShowSky = false, Mode = mode }; + if (normal is { } n) + { + renderer.AddMesh(new Illusion.Domain.MeshData + { + Name = "quad", + World = Matrix4x4.Identity, + Positions = [new Vector3(-1, -1, 0), new Vector3(1, -1, 0), new Vector3(1, 1, 0), new Vector3(-1, 1, 0)], + Normals = [n, n, n, n], + UVs = [new Vector2(0, 0), new Vector2(1, 0), new Vector2(1, 1), new Vector2(0, 1)], + Indices = [0, 1, 2, 0, 2, 3], + Parts = [new Illusion.Domain.MeshPart(0, 6, null)], + }); + } + const int S = 64; + target = new SharedRenderTarget(gpu, S, S); + renderer.Camera.AspectRatio = 1f; + renderer.Camera.LookAt(new Vector3(0f, -2f, 2f), Vector3.Zero); + renderer.Render(target); + return Pixel(RenderTargetReadback.Read(gpu, target), S, S / 2, S / 2); + } + finally + { + target?.Dispose(); + renderer?.Dispose(); + gpu?.Dispose(); + } + } + + private static (float r, float g, float b) Pixel(byte[] bgra, int width, int x, int y) + { + int idx = ((y * width) + x) * 4; + return (bgra[idx + 2] / 255f, bgra[idx + 1] / 255f, bgra[idx + 0] / 255f); + } +} From 1e012f54b61d1c93daeb6b21dacf6f1c63446416 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:28:41 +0300 Subject: [PATCH 036/102] fix: an imported object brings textures its own archive does not hold A weapon lying in a shop is drawn with textures of weapons.sds; the import looked for them in the shop's archive only, reported them as 'in neither archive' and the object stood white in the district. A texture the source lacks is now taken from whichever extracted archive holds it - the game itself ships the same texture in many archives - and the notice names where it came from. --probe-object-transplant: 43/0 (+ a shop weapon's texture comes from weapons.sds byte for byte, none is written when no archive holds it). --- src/Illusion.Assets/Sds/ArchiveCarry.cs | 55 +++++++++++++++---- .../Probes/ObjectTransplantProbes.cs | 39 +++++++++++++ .../Viewport/ObjectImportController.cs | 5 +- 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index 5ead2ad..046ac08 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -20,6 +20,13 @@ namespace Illusion.Assets.Sds; /// left alone and shared. /// /// +/// A texture need not be in the object's own archive. A weapon lying in a shop is drawn with textures that +/// live in weapons.sds, which the game has loaded beside the shop and may not have loaded where the +/// object lands. Such a texture is taken from whichever extracted archive holds it — the game itself ships the +/// same texture in many archives (every cutscene archive with a gun in it carries the gun's) — so the object +/// arrives with everything it is drawn with. +/// +/// /// This writes the working copy at once, not at Save — the viewport looks a texture up the moment it builds /// a mesh. So an import that is undone, or a scene closed without saving, leaves textures nothing uses. Every /// texture carried is therefore written down beside the working copy (), and each @@ -34,17 +41,20 @@ public static class ArchiveCarry /// Textures added to the destination. /// Item descriptions added to the destination, by hash. /// Whether a prefab entry was added. - /// Textures neither archive carries — they live in an archive of their own that - /// the game loads beside the source's, and may not be loaded where the destination is. + /// Textures neither archive carries and no extracted archive was found to hold. /// Collision hashes no item description of the source answers to. + /// Those of that came from a third archive, with its name. public sealed record Report( IReadOnlyList Textures, IReadOnlyList ItemDescriptions, bool Prefab, - IReadOnlyList Elsewhere, IReadOnlyList Unresolved); + IReadOnlyList Elsewhere, IReadOnlyList Unresolved, + IReadOnlyList<(string Texture, string Archive)> Borrowed); /// Carries the three kinds from one extracted folder to another. /// The actor's definition name, or null/empty for an object no actor places. + /// Where a texture the source does not hold is: its file inside some other + /// extracted archive, or null. The index of the whole mirror when not given. public static Report Carry(string fromDir, string toDir, IReadOnlyCollection materialHashes, - IReadOnlyCollection collisionHashes, string? definition) + IReadOnlyCollection collisionHashes, string? definition, Func? findElsewhere = null) { ArgumentException.ThrowIfNullOrEmpty(fromDir); ArgumentException.ThrowIfNullOrEmpty(toDir); @@ -56,18 +66,21 @@ public static Report Carry(string fromDir, string toDir, IReadOnlyCollection(); var elsewhere = new List(); - CarryTextures(from, to, fromDir, toDir, materialHashes, textures, elsewhere); + var borrowed = new List<(string Texture, string Archive)>(); + CarryTextures(from, to, fromDir, toDir, materialHashes, textures, elsewhere, borrowed, + findElsewhere ?? TextureSearchIndex.FindPath); var descriptions = new List(); var unresolved = new List(); CarryItemDescriptions(from, to, fromDir, toDir, collisionHashes, descriptions, unresolved); bool prefab = !string.IsNullOrEmpty(definition) && CarryPrefab(from, to, fromDir, toDir, Fnv64.Hash(definition)); - return new Report(textures, descriptions, prefab, elsewhere, unresolved); + return new Report(textures, descriptions, prefab, elsewhere, unresolved, borrowed); } private static void CarryTextures(SdsManifest from, SdsManifest to, string fromDir, string toDir, - IReadOnlyCollection materialHashes, List added, List elsewhere) + IReadOnlyCollection materialHashes, List added, List elsewhere, + List<(string Texture, string Archive)> borrowed, Func findElsewhere) { MafiaMaterials.EnsureLoaded(); var wanted = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -83,23 +96,43 @@ private static void CarryTextures(SdsManifest from, SdsManifest to, string fromD foreach (string texture in wanted.Order(StringComparer.OrdinalIgnoreCase)) { if (to.HasFile(texture)) continue; + + // From the object's own archive, or from whichever other extracted archive holds it. + SdsManifest holder = from; + string holderDir = fromDir; if (!from.HasFile(texture)) { - elsewhere.Add(texture); - continue; + if (HolderOf(texture, toDir, findElsewhere) is not { } third) + { + elsewhere.Add(texture); + continue; + } + (holder, holderDir) = third; } - if (!CopyEntry(from, to, fromDir, toDir, texture)) continue; + if (!CopyEntry(holder, to, holderDir, toDir, texture)) continue; added.Add(texture); + if (!ReferenceEquals(holder, from)) borrowed.Add((texture, Path.GetFileName(holderDir))); Remember(toDir, texture); TextureSearchIndex.Register(Path.Combine(toDir, texture)); // A texture stored split keeps its top level in a companion entry, and its own entry says so — // one without the other is a chain the game streams and does not find. string companion = SdsImportTypes.MipNameFor(texture); - if (from.HasFile(companion) && !to.HasFile(companion)) CopyEntry(from, to, fromDir, toDir, companion); + if (holder.HasFile(companion) && !to.HasFile(companion)) CopyEntry(holder, to, holderDir, toDir, companion); } } + // The extracted archive, other than the destination, whose manifest lists the texture. + private static (SdsManifest Manifest, string Dir)? HolderOf(string texture, string toDir, Func find) + { + string? path = find(texture); + string? dir = path == null ? null : Path.GetDirectoryName(path); + if (dir == null || !File.Exists(path) || !File.Exists(Path.Combine(dir, "SDSContent.xml"))) return null; + if (string.Equals(Path.GetFullPath(dir), Path.GetFullPath(toDir), StringComparison.OrdinalIgnoreCase)) return null; + SdsManifest manifest = SdsManifest.Load(dir); + return manifest.HasFile(texture) ? (manifest, dir) : null; + } + private static void CarryItemDescriptions(SdsManifest from, SdsManifest to, string fromDir, string toDir, IReadOnlyCollection collisionHashes, List added, List unresolved) { diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 7978e83..9b75b9f 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -115,6 +115,45 @@ void Check(string name, bool ok, string detail = "") Check("carrying the same things a second time adds nothing", again.ItemDescriptions.Count == 0 && !again.Prefab && again.Textures.Count == 0); + // A texture the object's archive does not hold comes from the archive that does: a weapon on the + // shop's shelf is drawn with textures of weapons.sds, which neither the shop nor the district has. + { + Illusion.Assets.MafiaMaterials.EnsureLoaded(); + SdsManifest shop = SdsManifest.Load(sourceDir); + SdsManifest scratchManifest = SdsManifest.Load(dir); + string weaponsDir = SdsMeshLoader.EnsureExtracted( + new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "weapons", "weapons.sds"))); + (ulong Hash, string Texture)? foreign = null; + foreach (ulong hash in theirs.FrameMaterials.Values.SelectMany(b => b.Materials).SelectMany(l => l).Select(m => m.MaterialHash).Distinct()) + { + string? texture = (Illusion.Assets.MafiaMaterials.Collection?.FindByHash(hash)?.CollectTextures() ?? []) + .FirstOrDefault(t => t.Length > 0 && !shop.HasFile(t) && !scratchManifest.HasFile(t) + && File.Exists(Path.Combine(weaponsDir, t))); + if (texture == null) continue; + foreign = (hash, texture); + break; + } + Check("the shop draws something with a texture of weapons.sds", foreign != null, foreign?.Texture ?? ""); + if (foreign is { } wanted) + { + ArchiveCarry.Report none = ArchiveCarry.Carry(sourceDir, dir, [wanted.Hash], [], null, _ => null); + Check("a texture no archive is found to hold is reported, and nothing is written for it", + none.Elsewhere.Contains(wanted.Texture) && !none.Textures.Contains(wanted.Texture) && none.Borrowed.Count == 0 + && !File.Exists(Path.Combine(dir, wanted.Texture))); + ArchiveCarry.Report lent = ArchiveCarry.Carry(sourceDir, dir, [wanted.Hash], [], null, + name => Path.Combine(weaponsDir, name)); + Check("a texture a third archive holds is taken from it", + lent.Textures.Contains(wanted.Texture) && !lent.Elsewhere.Contains(wanted.Texture) + && lent.Borrowed.Any(b => b.Texture == wanted.Texture && b.Archive == Path.GetFileName(weaponsDir)) + && File.Exists(Path.Combine(dir, wanted.Texture)) && SdsManifest.Load(dir).HasFile(wanted.Texture), + $"{wanted.Texture} from {string.Join(", ", lent.Borrowed.Select(b => b.Archive).Distinct())}"); + Check("with the bytes it has there", + File.ReadAllBytes(Path.Combine(dir, wanted.Texture)).AsSpan().SequenceEqual(File.ReadAllBytes(Path.Combine(weaponsDir, wanted.Texture)))); + Check("and a second carry adds nothing", + ArchiveCarry.Carry(sourceDir, dir, [wanted.Hash], [], null, name => Path.Combine(weaponsDir, name)).Textures.Count == 0); + } + } + // ── A prop an actor places ── FrameObjectBase propRoot = theirPlacements.TargetOf(prop)!; TransplantedObject? carriedProp = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_prop", diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index 14f7b09..b4ffc20 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -212,7 +212,10 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO $"Imported '{name}' from {sds.Name} as '{newName}' ({kind}): {outcome.Frames} frame(s), " + $"{outcome.Meshes} mesh(es), {carry.Textures.Count} texture(s), {carry.ItemDescriptions.Count} item " + $"description(s){(carry.Prefab ? ", a prefab entry" : "")} carried" - + (carry.Elsewhere.Count > 0 ? $"; {carry.Elsewhere.Count} texture(s) are in neither archive" : "") + + (carry.Borrowed.Count > 0 + ? $"; {carry.Borrowed.Count} of the textures came from {string.Join(", ", carry.Borrowed.Select(b => b.Archive).Distinct())}" + : "") + + (carry.Elsewhere.Count > 0 ? $"; {carry.Elsewhere.Count} texture(s) are in no extracted archive" : "") + (carry.Unresolved.Count > 0 ? $"; {carry.Unresolved.Count} collision hull(s) found no description" : "") + $"; collision: {collision}", isError: carry.Unresolved.Count > 0); From a4d1eaa9c1cc655750de508e2f5765f3edeb199d Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:33:00 +0300 Subject: [PATCH 037/102] fix(viewport): crash props are drawn with their textures, not black The instanced pass never set Tint. The shared pixel shader multiplies every texel by it, an unset one is zero, and every instanced copy - the trees, lamps and benches of a district - came out black in the Render and Material Preview modes (Solid has no texel to multiply). It is white now, as the struct's own comment said it was. --probe-vertex-normals renders an instanced copy of its quad beside the placed one: black before, the same colour now. --- src/Illusion.Rendering/Passes/SceneRenderer.cs | 3 +++ src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs | 10 ++++++++-- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/src/Illusion.Rendering/Passes/SceneRenderer.cs b/src/Illusion.Rendering/Passes/SceneRenderer.cs index 49b8f7c..48439ea 100644 --- a/src/Illusion.Rendering/Passes/SceneRenderer.cs +++ b/src/Illusion.Rendering/Passes/SceneRenderer.cs @@ -790,6 +790,9 @@ private int RenderInstanced(ComPtr ctx, Matrix4x4 viewProj, ViewProj = viewProj, LightDir = new Vector4(lightDir, 0f), BaseColor = baseColor, + // White, said out loud: the shared pixel shader multiplies every texel by it, and left unset it is + // zero — every crash prop in a district (trees, lamps, benches) was drawn black in the textured modes. + Tint = Vector4.One, Lighting = lighting, }; _instShader.UpdateConstants(ctx, ref consts); diff --git a/src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs b/src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs index 3930bad..8f33237 100644 --- a/src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs +++ b/src/Illusion/Diagnostics/Probes/VertexNormalProbes.cs @@ -14,7 +14,8 @@ namespace Illusion.Diagnostics.Probes; /// /// Which meshes of an archive carry no normals, and what the decode hands the renderer for them. A vertex /// declaration without (foliage, which the game lights by vertex colour) -/// decodes to zero normals, and a zero normal is what a surface shader cannot light. +/// decodes to zero normals, and a zero normal is what a surface shader cannot light. And the other way a +/// surface came out black: an instanced copy (a crash prop) drawn with no tint. /// internal static class VertexNormalProbes { @@ -107,6 +108,10 @@ private static void RenderBareQuad(Action check) check($"{mode}: a quad with no normals is lit like one facing the sky", Distance(bare, lit) < 0.02f && bare.r + bare.g + bare.b > 0.3f, $"facing up ({lit.r:F2}, {lit.g:F2}, {lit.b:F2}), no normals ({bare.r:F2}, {bare.g:F2}, {bare.b:F2})"); + (float r, float g, float b) copy = CentrePixel(mode, Vector3.UnitZ, instanced: true); + check($"{mode}: an instanced copy of it is drawn the colour the placed one is", + Distance(copy, lit) < 0.02f, + $"placed ({lit.r:F2}, {lit.g:F2}, {lit.b:F2}), instanced ({copy.r:F2}, {copy.g:F2}, {copy.b:F2})"); } } @@ -114,7 +119,7 @@ private static float Distance((float r, float g, float b) a, (float r, float g, MathF.Abs(a.r - b.r) + MathF.Abs(a.g - b.g) + MathF.Abs(a.b - b.b); // What the middle of the frame shows with a 2 m quad on the ground under an oblique camera. - private static (float r, float g, float b) CentrePixel(RenderMode mode, Vector3? normal) + private static (float r, float g, float b) CentrePixel(RenderMode mode, Vector3? normal, bool instanced = false) { GpuContext? gpu = null; SceneRenderer? renderer = null; @@ -134,6 +139,7 @@ private static (float r, float g, float b) CentrePixel(RenderMode mode, Vector3? UVs = [new Vector2(0, 0), new Vector2(1, 0), new Vector2(1, 1), new Vector2(0, 1)], Indices = [0, 1, 2, 0, 2, 3], Parts = [new Illusion.Domain.MeshPart(0, 6, null)], + Instances = instanced ? [Matrix4x4.Identity] : null, }); } const int S = 64; From c6fc3239dc537d6c741400c9eba326e8dcb46676 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 17:28:41 +0300 Subject: [PATCH 038/102] fix: an imported object brings textures its own archive does not hold A weapon lying in a shop is drawn with textures of weapons.sds; the import looked for them in the shop's archive only, reported them as 'in neither archive' and the object stood white in the district. A texture the source lacks is now taken from whichever extracted archive holds it - the game itself ships the same texture in many archives - and the notice names where it came from. --probe-object-transplant: 43/0 (+ a shop weapon's texture comes from weapons.sds byte for byte, none is written when no archive holds it). --- src/Illusion.Assets/Sds/ArchiveCarry.cs | 55 +++++++++++++++---- .../Probes/ObjectTransplantProbes.cs | 39 +++++++++++++ .../Viewport/ObjectImportController.cs | 5 +- 3 files changed, 87 insertions(+), 12 deletions(-) diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index 5ead2ad..046ac08 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -20,6 +20,13 @@ namespace Illusion.Assets.Sds; /// left alone and shared. /// /// +/// A texture need not be in the object's own archive. A weapon lying in a shop is drawn with textures that +/// live in weapons.sds, which the game has loaded beside the shop and may not have loaded where the +/// object lands. Such a texture is taken from whichever extracted archive holds it — the game itself ships the +/// same texture in many archives (every cutscene archive with a gun in it carries the gun's) — so the object +/// arrives with everything it is drawn with. +/// +/// /// This writes the working copy at once, not at Save — the viewport looks a texture up the moment it builds /// a mesh. So an import that is undone, or a scene closed without saving, leaves textures nothing uses. Every /// texture carried is therefore written down beside the working copy (), and each @@ -34,17 +41,20 @@ public static class ArchiveCarry /// Textures added to the destination. /// Item descriptions added to the destination, by hash. /// Whether a prefab entry was added. - /// Textures neither archive carries — they live in an archive of their own that - /// the game loads beside the source's, and may not be loaded where the destination is. + /// Textures neither archive carries and no extracted archive was found to hold. /// Collision hashes no item description of the source answers to. + /// Those of that came from a third archive, with its name. public sealed record Report( IReadOnlyList Textures, IReadOnlyList ItemDescriptions, bool Prefab, - IReadOnlyList Elsewhere, IReadOnlyList Unresolved); + IReadOnlyList Elsewhere, IReadOnlyList Unresolved, + IReadOnlyList<(string Texture, string Archive)> Borrowed); /// Carries the three kinds from one extracted folder to another. /// The actor's definition name, or null/empty for an object no actor places. + /// Where a texture the source does not hold is: its file inside some other + /// extracted archive, or null. The index of the whole mirror when not given. public static Report Carry(string fromDir, string toDir, IReadOnlyCollection materialHashes, - IReadOnlyCollection collisionHashes, string? definition) + IReadOnlyCollection collisionHashes, string? definition, Func? findElsewhere = null) { ArgumentException.ThrowIfNullOrEmpty(fromDir); ArgumentException.ThrowIfNullOrEmpty(toDir); @@ -56,18 +66,21 @@ public static Report Carry(string fromDir, string toDir, IReadOnlyCollection(); var elsewhere = new List(); - CarryTextures(from, to, fromDir, toDir, materialHashes, textures, elsewhere); + var borrowed = new List<(string Texture, string Archive)>(); + CarryTextures(from, to, fromDir, toDir, materialHashes, textures, elsewhere, borrowed, + findElsewhere ?? TextureSearchIndex.FindPath); var descriptions = new List(); var unresolved = new List(); CarryItemDescriptions(from, to, fromDir, toDir, collisionHashes, descriptions, unresolved); bool prefab = !string.IsNullOrEmpty(definition) && CarryPrefab(from, to, fromDir, toDir, Fnv64.Hash(definition)); - return new Report(textures, descriptions, prefab, elsewhere, unresolved); + return new Report(textures, descriptions, prefab, elsewhere, unresolved, borrowed); } private static void CarryTextures(SdsManifest from, SdsManifest to, string fromDir, string toDir, - IReadOnlyCollection materialHashes, List added, List elsewhere) + IReadOnlyCollection materialHashes, List added, List elsewhere, + List<(string Texture, string Archive)> borrowed, Func findElsewhere) { MafiaMaterials.EnsureLoaded(); var wanted = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -83,23 +96,43 @@ private static void CarryTextures(SdsManifest from, SdsManifest to, string fromD foreach (string texture in wanted.Order(StringComparer.OrdinalIgnoreCase)) { if (to.HasFile(texture)) continue; + + // From the object's own archive, or from whichever other extracted archive holds it. + SdsManifest holder = from; + string holderDir = fromDir; if (!from.HasFile(texture)) { - elsewhere.Add(texture); - continue; + if (HolderOf(texture, toDir, findElsewhere) is not { } third) + { + elsewhere.Add(texture); + continue; + } + (holder, holderDir) = third; } - if (!CopyEntry(from, to, fromDir, toDir, texture)) continue; + if (!CopyEntry(holder, to, holderDir, toDir, texture)) continue; added.Add(texture); + if (!ReferenceEquals(holder, from)) borrowed.Add((texture, Path.GetFileName(holderDir))); Remember(toDir, texture); TextureSearchIndex.Register(Path.Combine(toDir, texture)); // A texture stored split keeps its top level in a companion entry, and its own entry says so — // one without the other is a chain the game streams and does not find. string companion = SdsImportTypes.MipNameFor(texture); - if (from.HasFile(companion) && !to.HasFile(companion)) CopyEntry(from, to, fromDir, toDir, companion); + if (holder.HasFile(companion) && !to.HasFile(companion)) CopyEntry(holder, to, holderDir, toDir, companion); } } + // The extracted archive, other than the destination, whose manifest lists the texture. + private static (SdsManifest Manifest, string Dir)? HolderOf(string texture, string toDir, Func find) + { + string? path = find(texture); + string? dir = path == null ? null : Path.GetDirectoryName(path); + if (dir == null || !File.Exists(path) || !File.Exists(Path.Combine(dir, "SDSContent.xml"))) return null; + if (string.Equals(Path.GetFullPath(dir), Path.GetFullPath(toDir), StringComparison.OrdinalIgnoreCase)) return null; + SdsManifest manifest = SdsManifest.Load(dir); + return manifest.HasFile(texture) ? (manifest, dir) : null; + } + private static void CarryItemDescriptions(SdsManifest from, SdsManifest to, string fromDir, string toDir, IReadOnlyCollection collisionHashes, List added, List unresolved) { diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 7978e83..9b75b9f 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -115,6 +115,45 @@ void Check(string name, bool ok, string detail = "") Check("carrying the same things a second time adds nothing", again.ItemDescriptions.Count == 0 && !again.Prefab && again.Textures.Count == 0); + // A texture the object's archive does not hold comes from the archive that does: a weapon on the + // shop's shelf is drawn with textures of weapons.sds, which neither the shop nor the district has. + { + Illusion.Assets.MafiaMaterials.EnsureLoaded(); + SdsManifest shop = SdsManifest.Load(sourceDir); + SdsManifest scratchManifest = SdsManifest.Load(dir); + string weaponsDir = SdsMeshLoader.EnsureExtracted( + new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "weapons", "weapons.sds"))); + (ulong Hash, string Texture)? foreign = null; + foreach (ulong hash in theirs.FrameMaterials.Values.SelectMany(b => b.Materials).SelectMany(l => l).Select(m => m.MaterialHash).Distinct()) + { + string? texture = (Illusion.Assets.MafiaMaterials.Collection?.FindByHash(hash)?.CollectTextures() ?? []) + .FirstOrDefault(t => t.Length > 0 && !shop.HasFile(t) && !scratchManifest.HasFile(t) + && File.Exists(Path.Combine(weaponsDir, t))); + if (texture == null) continue; + foreign = (hash, texture); + break; + } + Check("the shop draws something with a texture of weapons.sds", foreign != null, foreign?.Texture ?? ""); + if (foreign is { } wanted) + { + ArchiveCarry.Report none = ArchiveCarry.Carry(sourceDir, dir, [wanted.Hash], [], null, _ => null); + Check("a texture no archive is found to hold is reported, and nothing is written for it", + none.Elsewhere.Contains(wanted.Texture) && !none.Textures.Contains(wanted.Texture) && none.Borrowed.Count == 0 + && !File.Exists(Path.Combine(dir, wanted.Texture))); + ArchiveCarry.Report lent = ArchiveCarry.Carry(sourceDir, dir, [wanted.Hash], [], null, + name => Path.Combine(weaponsDir, name)); + Check("a texture a third archive holds is taken from it", + lent.Textures.Contains(wanted.Texture) && !lent.Elsewhere.Contains(wanted.Texture) + && lent.Borrowed.Any(b => b.Texture == wanted.Texture && b.Archive == Path.GetFileName(weaponsDir)) + && File.Exists(Path.Combine(dir, wanted.Texture)) && SdsManifest.Load(dir).HasFile(wanted.Texture), + $"{wanted.Texture} from {string.Join(", ", lent.Borrowed.Select(b => b.Archive).Distinct())}"); + Check("with the bytes it has there", + File.ReadAllBytes(Path.Combine(dir, wanted.Texture)).AsSpan().SequenceEqual(File.ReadAllBytes(Path.Combine(weaponsDir, wanted.Texture)))); + Check("and a second carry adds nothing", + ArchiveCarry.Carry(sourceDir, dir, [wanted.Hash], [], null, name => Path.Combine(weaponsDir, name)).Textures.Count == 0); + } + } + // ── A prop an actor places ── FrameObjectBase propRoot = theirPlacements.TargetOf(prop)!; TransplantedObject? carriedProp = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_prop", diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index 14f7b09..b4ffc20 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -212,7 +212,10 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO $"Imported '{name}' from {sds.Name} as '{newName}' ({kind}): {outcome.Frames} frame(s), " + $"{outcome.Meshes} mesh(es), {carry.Textures.Count} texture(s), {carry.ItemDescriptions.Count} item " + $"description(s){(carry.Prefab ? ", a prefab entry" : "")} carried" - + (carry.Elsewhere.Count > 0 ? $"; {carry.Elsewhere.Count} texture(s) are in neither archive" : "") + + (carry.Borrowed.Count > 0 + ? $"; {carry.Borrowed.Count} of the textures came from {string.Join(", ", carry.Borrowed.Select(b => b.Archive).Distinct())}" + : "") + + (carry.Elsewhere.Count > 0 ? $"; {carry.Elsewhere.Count} texture(s) are in no extracted archive" : "") + (carry.Unresolved.Count > 0 ? $"; {carry.Unresolved.Count} collision hull(s) found no description" : "") + $"; collision: {collision}", isError: carry.Unresolved.Count > 0); From 141f47e6d3cd37df1120e90fdd9d97d8658516ee Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:20:04 +0300 Subject: [PATCH 039/102] feat: hide triangles of a stock mesh without rebuilding it mesh_hide_triangles points the indices of every triangle inside a world-space box at one vertex, on every level of detail, as one undoable edit - an opening cut into a painted door or shutter of a stock building. No vertex is touched, so the facade keeps the shadow-map UVs a rebuild through Blender loses (black patches over the whole wall). Reports what a box would take before it is asked to apply. --probe-hide-triangles: 11/0, in memory on a district's working copy. --- src/Illusion.Assets/Sds/TriangleHider.cs | 90 ++++++++++++++ src/Illusion.Mcp/EditorModels.cs | 7 ++ src/Illusion.Mcp/IEditorSession.cs | 5 + src/Illusion.Mcp/Tools/EditorTools.cs | 24 ++++ src/Illusion/Diagnostics/ProbeRunner.cs | 6 + .../Diagnostics/Probes/HideTrianglesProbes.cs | 111 ++++++++++++++++++ src/Illusion/Diagnostics/Probes/McpProbes.cs | 1 + src/Illusion/Mcp/AppEditorSession.cs | 28 +++++ .../Viewport/GeometryEditController.cs | 98 ++++++++++++++++ 9 files changed, 370 insertions(+) create mode 100644 src/Illusion.Assets/Sds/TriangleHider.cs create mode 100644 src/Illusion/Diagnostics/Probes/HideTrianglesProbes.cs diff --git a/src/Illusion.Assets/Sds/TriangleHider.cs b/src/Illusion.Assets/Sds/TriangleHider.cs new file mode 100644 index 0000000..64fe0a1 --- /dev/null +++ b/src/Illusion.Assets/Sds/TriangleHider.cs @@ -0,0 +1,90 @@ +using System.Numerics; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Geometry; + +namespace Illusion.Assets.Sds; + +/// +/// Takes triangles out of a mesh without rebuilding it: a triangle is "hidden" by pointing its three indices +/// at one vertex, which the rasterizer draws as nothing. +/// +/// +/// This is how an opening is cut into a STOCK mesh — the painted door of a building one wants to walk +/// through. A rebuild through Blender cannot be trusted with that: a stock facade carries channels Blender +/// never sees (the shadow/occlusion-map UVs behind ShadowTexture), and vertices re-created from a +/// Blender mesh get wrong values for them — the whole facade comes back with black patches. Here no vertex +/// is touched and none is added or removed: the vertex buffer, the material ranges and every index outside +/// the hidden triangles stay exactly as shipped, on every level of detail. +/// +/// +public static class TriangleHider +{ + /// One triangle found: its level of detail, the material of its slot and its corners in world space. + public sealed record Triangle(int Lod, string Material, Vector3 A, Vector3 B, Vector3 C); + + /// One index buffer as it is and as it would be with the triangles hidden. Nothing is applied. + public sealed record Change(IndexBuffer Buffer, uint[] Before, uint[] After); + + /// What a box takes out of a mesh. + public sealed record Plan(IReadOnlyList Triangles, IReadOnlyList Changes); + + /// + /// The triangles of whose three corners all lie inside the world-space box, on every + /// level of detail, and the index buffers with them hidden. A triangle already hidden is not found again. + /// + /// The matrix the mesh is drawn at. + /// Only triangles of slots whose material name contains this, or null for any. + public static Plan Find(FrameObjectSingleMesh mesh, Matrix4x4 world, Vector3 min, Vector3 max, string? material = null) + { + ArgumentNullException.ThrowIfNull(mesh); + const float Slack = 1e-3f; + Vector3 lo = Vector3.Min(min, max) - new Vector3(Slack); + Vector3 hi = Vector3.Max(min, max) + new Vector3(Slack); + bool Inside(Vector3 p) => p.X >= lo.X && p.X <= hi.X && p.Y >= lo.Y && p.Y <= hi.Y && p.Z >= lo.Z && p.Z <= hi.Z; + + var triangles = new List(); + // By buffer, so two levels drawing from one buffer are edited as one. + var working = new Dictionary(); + int levels = mesh.Geometry?.LOD?.Length ?? 0; + List? slotsByLod = mesh.Material?.Materials; + for (int lod = 0; lod < levels; lod++) + { + DecodedMesh? decoded = SdsMeshLoader.DecodeLod(mesh, lod); + IndexBuffer? buffer = mesh.GetIndexBuffer(lod); + if (decoded == null || buffer == null || slotsByLod == null || lod >= slotsByLod.Count) continue; + if (!working.TryGetValue(buffer.Hash, out (IndexBuffer Buffer, uint[] Before, uint[] After) entry)) + { + uint[] before = buffer.GetData(); + working[buffer.Hash] = entry = (buffer, before, (uint[])before.Clone()); + } + + uint[] indices = entry.After; + Vector3[] positions = decoded.Positions; + foreach (MaterialStruct slot in slotsByLod[lod]) + { + string name = slot.MaterialName ?? ""; + if (material != null && !name.Contains(material, StringComparison.OrdinalIgnoreCase)) continue; + int end = Math.Min(slot.StartIndex + (slot.NumFaces * 3), indices.Length); + for (int i = slot.StartIndex; i + 2 < end; i += 3) + { + uint a = indices[i], b = indices[i + 1], c = indices[i + 2]; + if (a == b && b == c) continue; + if (a >= positions.Length || b >= positions.Length || c >= positions.Length) continue; + Vector3 pa = Vector3.Transform(positions[a], world); + Vector3 pb = Vector3.Transform(positions[b], world); + Vector3 pc = Vector3.Transform(positions[c], world); + if (!Inside(pa) || !Inside(pb) || !Inside(pc)) continue; + triangles.Add(new Triangle(lod, name, pa, pb, pc)); + indices[i + 1] = a; + indices[i + 2] = a; + } + } + } + + List changes = [.. working.Values + .Where(w => !w.Before.AsSpan().SequenceEqual(w.After)) + .Select(w => new Change(w.Buffer, w.Before, w.After))]; + return new Plan(triangles, changes); + } +} diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index 291ae11..5282bb2 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -41,6 +41,13 @@ public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, i /// One archive a car clone wrote, and the backup of what it replaced. public sealed record PackedArchive(string Archive, string? Backup); +/// One triangle of a mesh, in world space, with the material of its slot and its level of detail. +public sealed record TriangleInfo(int Lod, string Material, float[] A, float[] B, float[] C); + +/// What a box takes out of a mesh: how many triangles on each level of detail, whether they were +/// hidden or only found, and the first of them. +public sealed record HiddenTrianglesInfo(int Triangles, IReadOnlyList PerLod, bool Applied, IReadOnlyList Sample); + /// One car built under another's name: the model name the replaced archives are keyed by, each archive /// with the backup of what it held, and what was left as it was. public sealed record CarSubstituteInfo(string Model, IReadOnlyList Packed, IReadOnlyList Notes); diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index 96da26d..3018bd3 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -153,6 +153,11 @@ public interface IEditorSession /// nothing of the game is written. Null on success. string? ExportCarForM2o(string car, string? output, string? resource, out M2oExportInfo? result); + /// Finds — and with hides, as one undoable edit — the triangles of a mesh + /// whose corners all lie inside a world-space box, without rebuilding the mesh. Null on success. + string? HideTriangles(string name, float[] boxMin, float[] boxMax, string? material, bool apply, int sample, + out HiddenTrianglesInfo? result); + string? Undo(); string? Redo(); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index eae7e13..0dd4cb8 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -204,6 +204,30 @@ public static async Task ArchiveBuild( } } + [McpServerTool(Name = "mesh_hide_triangles")] + [Description("Cut an opening into a mesh WITHOUT rebuilding it: the triangles of the named mesh whose three corners all lie inside a world-space box are hidden on every level of detail (their indices are pointed at one vertex). No vertex is touched, so a stock facade keeps the channels Blender never sees (shadow-map UVs) — use this, not a Blender push, to open a painted door or garage shutter of a stock building. By default it only REPORTS what the box would take (count per LOD and the first triangles with their material and corners); pass apply=true to hide them as one undoable edit. Saved by editor_save, packed by editor_build. Refused for a mesh that shares its geometry with other objects.")] + public static async Task MeshHideTriangles( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The mesh, by name or path suffix (scene_find has the names).")] string name, + [Description("Lower corner of the box, world space [x, y, z].")] float[] boxMin, + [Description("Upper corner of the box, world space [x, y, z].")] float[] boxMax, + [Description("Only triangles whose material name contains this. Omit for any material.")] string? material = null, + [Description("Hide the triangles. Default false: report only.")] bool apply = false, + [Description("How many of the found triangles to list (0-500). Default 40.")] int sample = 40) + { + try + { + HiddenTrianglesInfo? result = null; + string? refused = await ui.RunAsync(() => editor.HideTriangles(name, boxMin, boxMax, material, apply, sample, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, hidden = result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + [McpServerTool(Name = "car_substitute")] [Description("Build one car under ANOTHER car's name: pc\\sds\\cars\\.sds is replaced by the source car's model, with the root frame, name table, prefab entry, entity data and buffers keyed by the target's model name. No table is touched — the game lists the target as before and finds the source's shape and tuning in its archive. For trying a car where nothing can be registered (a multiplayer that spawns from a fixed list of names). A timestamped backup of each replaced archive is kept in cars\\backups; the winter _z twin is replaced only where both cars have one. This OVERWRITES game files and the target's working copy: the game must not be running.")] public static async Task CarSubstitute( diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 34c5a89..24e0ba3 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -190,6 +190,12 @@ public static bool TryRun(string[] args) case "--probe-vertex-normals": VertexNormalProbes.RunVertexNormalProbe(args.Length >= 2 ? args[1] : @"city_crash\city_crash.sds"); return true; + // Hiding triangles of a stock mesh without a rebuild, in memory only: what a box finds, that + // nothing but the found triangles' indices changes, on every level of detail. + // Output: %TEMP%\illusion_hide_triangles.txt + case "--probe-hide-triangles": + HideTrianglesProbes.RunHideTrianglesProbe(args.Length >= 2 ? args[1] : "uppertown"); + return true; case "--probe-geometry-sweep": GeometrySweepProbes.RunGeometrySweepProbe(); return true; diff --git a/src/Illusion/Diagnostics/Probes/HideTrianglesProbes.cs b/src/Illusion/Diagnostics/Probes/HideTrianglesProbes.cs new file mode 100644 index 0000000..10cb8a1 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/HideTrianglesProbes.cs @@ -0,0 +1,111 @@ +using System.IO; +using System.Numerics; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Hiding triangles of a stock mesh without rebuilding it (), on a district read +/// from its working copy and never written: what a box finds, that only the found triangles' indices change +/// and nothing else of the buffer does, that a second look finds nothing, and what a material filter leaves. +/// +internal static class HideTrianglesProbes +{ + // Output: %TEMP%\illusion_hide_triangles.txt + internal static void RunHideTrianglesProbe(string district) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_hide_triangles.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)}"); + } + + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + var sds = new FileInfo(Path.Combine(MafiaEnvironment.CityFolder, district + ".sds")); + if (!sds.Exists) { sb.AppendLine("no such district: " + sds.FullName); return; } + ExtractedSds scene = ExtractedSds.Load(SdsMeshLoader.EnsureExtracted(sds)); + + // The mesh with the most levels of detail and materials the district has, so both are exercised + // where the district has them at all. + FrameObjectSingleMesh? mesh = scene.FrameResource!.FrameObjects.Values.OfType() + .Where(m => m.Geometry?.LOD is { Length: > 0 } && m.Material?.Materials is { Count: > 0 } slots + && slots[0].Length > 0 && slots[0][0].NumFaces > 0) + .OrderByDescending(m => m.Geometry.LOD.Length) + .ThenByDescending(m => m.Material.Materials[0].Length) + .ThenByDescending(m => m.Geometry.LOD[0].NumVerts) + .FirstOrDefault(m => SdsMeshLoader.DecodeLod(m, 0) != null); + Check("a mesh to try", mesh != null, mesh == null ? "" : + $"{mesh.Name.String}: {mesh.Geometry.LOD.Length} level(s), {mesh.Material.Materials[0].Length} material(s)"); + if (mesh == null) return; + Matrix4x4 world = mesh.WorldTransform; + DecodedMesh decoded = SdsMeshLoader.DecodeLod(mesh, 0)!; + uint[] original = (uint[])decoded.Indices.Clone(); + + // A box around the first triangle of the first slot, a little larger than the triangle itself. + var slot = mesh.Material.Materials[0][0]; + Vector3[] corners = [.. Enumerable.Range(0, 3).Select(k => Vector3.Transform(decoded.Positions[original[slot.StartIndex + k]], world))]; + Vector3 min = Vector3.Min(corners[0], Vector3.Min(corners[1], corners[2])) - new Vector3(0.05f); + Vector3 max = Vector3.Max(corners[0], Vector3.Max(corners[1], corners[2])) + new Vector3(0.05f); + + TriangleHider.Plan plan = TriangleHider.Find(mesh, world, min, max); + Check("the box finds its triangle", plan.Triangles.Any(t => t.Lod == 0 && t.A == corners[0] && t.B == corners[1] && t.C == corners[2]), + $"{plan.Triangles.Count} triangles over {plan.Changes.Count} buffers"); + Check("every triangle found lies inside the box", plan.Triangles.All(t => + new[] { t.A, t.B, t.C }.All(p => p.X >= min.X - 0.01f && p.X <= max.X + 0.01f && p.Y >= min.Y - 0.01f + && p.Y <= max.Y + 0.01f && p.Z >= min.Z - 0.01f && p.Z <= max.Z + 0.01f))); + Check("finding changes nothing", decoded.Indices.AsSpan().SequenceEqual(original) + && mesh.GetIndexBuffer(0)!.GetData().AsSpan().SequenceEqual(original)); + + TriangleHider.Change? first = plan.Changes.FirstOrDefault(c => ReferenceEquals(c.Buffer, mesh.GetIndexBuffer(0))); + Check("the first level's buffer is among the changes", first != null); + if (first == null) return; + int differing = 0, wholeTriangles = 0, degenerate = 0; + for (int i = 0; i + 2 < first.After.Length; i += 3) + { + bool changed = first.After[i] != first.Before[i] || first.After[i + 1] != first.Before[i + 1] || first.After[i + 2] != first.Before[i + 2]; + if (!changed) continue; + differing++; + if (first.After[i] == first.Before[i]) wholeTriangles++; + if (first.After[i] == first.After[i + 1] && first.After[i + 1] == first.After[i + 2]) degenerate++; + } + int lod0 = plan.Triangles.Count(t => t.Lod == 0); + Check("only the found triangles' indices change, each to one vertex of its own", + first.After.Length == first.Before.Length && differing == lod0 && wholeTriangles == lod0 && degenerate == lod0, + $"{differing} triangles differ of {lod0} found"); + + // Applied in memory (nothing is saved): a second look finds nothing, and putting it back restores every index. + foreach (TriangleHider.Change change in plan.Changes) change.Buffer.SetData(change.After); + Check("hidden triangles are not found again", TriangleHider.Find(mesh, world, min, max).Triangles.Count == 0); + Check("the vertex count is what it was", SdsMeshLoader.DecodeLod(mesh, 0)!.NumVerts == decoded.NumVerts); + foreach (TriangleHider.Change change in plan.Changes) change.Buffer.SetData(change.Before); + Check("restoring puts every index back", mesh.GetIndexBuffer(0)!.GetData().AsSpan().SequenceEqual(original) + && TriangleHider.Find(mesh, world, min, max).Triangles.Count == plan.Triangles.Count); + + string wanted = slot.MaterialName ?? ""; + Check("a material filter keeps to that material", + TriangleHider.Find(mesh, world, min, max, wanted).Triangles.All(t => t.Material == wanted) + && TriangleHider.Find(mesh, world, min, max, "no_such_material_name").Triangles.Count == 0, wanted); + Check("a box elsewhere finds nothing", + TriangleHider.Find(mesh, world, max + new Vector3(5000f), max + new Vector3(5001f)).Changes.Count == 0); + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + sb.Insert(0, $"HIDE TRIANGLES PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } +} diff --git a/src/Illusion/Diagnostics/Probes/McpProbes.cs b/src/Illusion/Diagnostics/Probes/McpProbes.cs index ab2e5dd..3f970cd 100644 --- a/src/Illusion/Diagnostics/Probes/McpProbes.cs +++ b/src/Illusion/Diagnostics/Probes/McpProbes.cs @@ -69,6 +69,7 @@ internal static class McpProbes "archive_build", "car_export_m2o", "car_substitute", + "mesh_hide_triangles", }; /// Records one assertion. A delegate rather than an Action so the optional diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index b1afcf4..47b580d 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -876,6 +876,34 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) } } + public string? HideTriangles(string name, float[] boxMin, float[] boxMax, string? material, bool apply, int sample, + out HiddenTrianglesInfo? result) + { + result = null; + if (TargetHost is not { } host) return TargetNotOpen; + if (boxMin is not { Length: 3 } || boxMax is not { Length: 3 }) return "boxMin and boxMax are [x, y, z]"; + if (Resolve(host, name, out SceneNode? node) is { } unresolved) return unresolved; + if (node!.Source is not Assets.Adapters.FrameNodeAdapter { Frame: Formats.Frames.ObjectTypes.FrameObjectSingleMesh mesh } adapter) + { + return $"'{name}' is a {node.Kind} — only a mesh has triangles to hide"; + } + if (apply && host.BridgeEditedCount > 0) return "a Blender session is open — blender_end first"; + + Assets.Sds.TriangleHider.Plan plan = Assets.Sds.TriangleHider.Find(mesh, ((IFrameNode)adapter).WorldTransform, + new Vector3(boxMin[0], boxMin[1], boxMin[2]), new Vector3(boxMax[0], boxMax[1], boxMax[2]), + string.IsNullOrWhiteSpace(material) ? null : material); + if (apply && plan.Changes.Count > 0 && host.GeometryEditing.HideTriangles(node, plan.Changes) is { } refused) return refused; + + static float[] P(Vector3 v) => [v.X, v.Y, v.Z]; + int levels = plan.Triangles.Count == 0 ? 0 : plan.Triangles.Max(t => t.Lod) + 1; + result = new HiddenTrianglesInfo( + plan.Triangles.Count, + [.. Enumerable.Range(0, levels).Select(l => plan.Triangles.Count(t => t.Lod == l))], + apply && plan.Changes.Count > 0, + [.. plan.Triangles.Take(Math.Clamp(sample, 0, 500)).Select(t => new TriangleInfo(t.Lod, t.Material, P(t.A), P(t.B), P(t.C)))]); + return null; + } + public string? Undo() { if (TargetHost is not { } host) return TargetNotOpen; diff --git a/src/Illusion/Viewport/GeometryEditController.cs b/src/Illusion/Viewport/GeometryEditController.cs index be7eab0..26b6766 100644 --- a/src/Illusion/Viewport/GeometryEditController.cs +++ b/src/Illusion/Viewport/GeometryEditController.cs @@ -1,6 +1,7 @@ using System.Numerics; using Illusion.Assets.Adapters; using Illusion.Assets.Bridge; +using Illusion.Assets.Sds; using Illusion.Domain; using Illusion.Formats.Frames.ObjectTypes; using Illusion.Rendering.Gpu; @@ -135,6 +136,103 @@ public List ApplyPushBatch( return outcomes; } + /// + /// Hides triangles of a mesh () as one undoable edit: the index buffers take + /// their planned contents and every level of detail the tree shows is redrawn. Refused for a mesh that + /// shares its geometry — the triangles would vanish from every object drawing it — and for instanced ones. + /// + /// Null when applied, or why not. + public string? HideTriangles(SceneNode node, IReadOnlyList changes) + { + if (node.Source is not FrameNodeAdapter { Frame: FrameObjectSingleMesh mesh } adapter) return "not a mesh"; + if (changes.Count == 0) return "nothing to hide"; + if (node.Mesh is { Instanced: true }) return "an instanced mesh is drawn many times over — its triangles cannot be hidden in one place"; + if (adapter.Document.GeometrySharers(mesh).Any()) return "other objects draw the same geometry — the triangles would vanish from all of them"; + if (_host.Rnd == null) return "the viewport is not rendering yet"; + + var edit = new HiddenTrianglesEdit(this, node, adapter.Document, mesh, changes); + edit.Redo(); + _host.Editing.History.Push(edit); + _host.RaiseSceneChanged(); + return null; + } + + // The rows that draw one mesh: its own (the first level) and a row per further level under it. + private static IEnumerable LevelRows(SceneNode node) + { + yield return node; + foreach (SceneNode child in node.Children) + { + if (child.Kind == "Lod" && ReferenceEquals(child.Source, node.Source)) yield return child; + } + } + + /// Index buffers swapped between two states, and the GPU meshes that draw each. + private sealed class HiddenTrianglesEdit : INodeEdit + { + private readonly GeometryEditController _owner; + private readonly SceneNode _node; + private readonly SceneDocumentAdapter _document; + private readonly FrameObjectSingleMesh _mesh; + private readonly IReadOnlyList _changes; + private readonly List<(SceneNode Row, GpuMesh? Shown, GpuMesh? Hidden)> _rows = []; + private bool _applied; + + public HiddenTrianglesEdit(GeometryEditController owner, SceneNode node, SceneDocumentAdapter document, + FrameObjectSingleMesh mesh, IReadOnlyList changes) + { + _owner = owner; + _node = node; + _document = document; + _mesh = mesh; + _changes = changes; + foreach (SceneNode row in LevelRows(node)) _rows.Add((row, row.Mesh, null)); + } + + public IEnumerable Nodes { get { yield return _node; } } + + public void Undo() => Apply(hidden: false); + + public void Redo() => Apply(hidden: true); + + private void Apply(bool hidden) + { + D3DImageHost host = _owner._host; + if (!host.Tree.IsInScene(_node) || host.Rnd == null) return; // streamed out — pruning backstop + foreach (TriangleHider.Change change in _changes) + { + change.Buffer.SetData(hidden ? change.After : change.Before); + _document.MarkIndexBufferDirty(change.Buffer.Hash); // a save may have written the other state + } + for (int i = 0; i < _rows.Count; i++) + { + (SceneNode row, GpuMesh? shown, GpuMesh? gone) = _rows[i]; + if (shown == null) continue; // a level the tree never drew + if (gone == null && hidden && SdsMeshLoader.BuildMeshData(_mesh, row.Lod) is { } data) + { + gone = host.Rnd.CreateMeshGpu(data); + gone.Owner = row; + _rows[i] = (row, shown, gone); + } + if (gone == null) continue; + _owner.SwapMesh(row, hidden ? shown : gone, hidden ? gone : shown); + } + host.Persistence.MarkFrameModified(_node); + _applied = hidden; + } + + // Dropped from history: whichever mesh of each pair is DETACHED belongs to this edit alone. + public void Discard() + { + foreach ((SceneNode _, GpuMesh? shown, GpuMesh? gone) in _rows) + { + if (gone == null) continue; + if (_applied) shown?.Dispose(); + else gone.Dispose(); + } + } + } + /// The other tree rows drawing the same geometry block as this node's mesh — the frames that just /// changed with it. Empty when the mesh is the block's only user, or when the sharers are not resident. private IEnumerable SharerNodes(SceneNode node) From a7f2a4eadea3326e224d77f1bd18dd89ddf155af Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:45:56 +0300 Subject: [PATCH 040/102] feat(mcp): scene_find finds a collision placement by its hull A placement used to be found only by its origin, which for a building's hull is nowhere near the building. Its bounds, vertex and triangle counts are now the hull's, and a box is tested against the hull's triangles - which hull closes a doorway is one query. --- src/Illusion.Mcp/Tools/EditorTools.cs | 2 +- src/Illusion/Mcp/AppEditorSession.cs | 68 ++++++++++++++++++++++----- 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 0dd4cb8..4ea6cb6 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -333,7 +333,7 @@ public static async Task OpenArea( } [McpServerTool(Name = "scene_find")] - [Description("Find objects in the loaded scene by a fragment of their name, by kind, and/or by a world-space box. Returns name, kind, tree path, position and bounds. With a box, a mesh is returned only when its TRIANGLES reach into the box (not merely its bounds), with TrianglesInBox and the extent of those triangles clipped to the box — the way to check that a volume is free before building in it. Objects without a mesh match a box by their position. Use it also to learn exact names before scene_select.")] + [Description("Find objects in the loaded scene by a fragment of their name, by kind, and/or by a world-space box. Returns name, kind, tree path, position and bounds. With a box, a mesh is returned only when its TRIANGLES reach into the box (not merely its bounds), with TrianglesInBox and the extent of those triangles clipped to the box — the way to check that a volume is free before building in it. Objects without a mesh match a box by their position. Use it also to learn exact names before scene_select. A collision placement (kind 'CollisionInstance', named 'instance N') is found by its HULL: its bounds, vertex and triangle counts are the hull's, and a box is tested against the hull's triangles.")] public static async Task Find( IEditorSession editor, IUiThreadMarshal ui, diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 47b580d..166ab62 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -216,17 +216,18 @@ public IReadOnlyList Find( if (nameContains != null && !node.Name.Contains(nameContains, StringComparison.OrdinalIgnoreCase)) continue; if (kind != null && !string.Equals(node.Kind, kind, StringComparison.OrdinalIgnoreCase)) continue; - // An instanced mesh's bounds span every copy across the map — not where this row is. - bool bounded = node.Mesh is { Instanced: false }; + // What the row stands where: a drawn mesh, or — for a collision placement — its hull. An instanced + // mesh has neither: its bounds span every copy across the map, not where this row is. + Shape? shape = ShapeOf(node); Vector3? position = node.Source is IFrameNode frame ? frame.WorldTransform.Translation : null; int? triangles = null; Vector3 insideMin = default, insideMax = default; if (lo is { } min && hi is { } max) { - if (bounded) + if (shape is { } solid) { - if (!Overlaps(node.Mesh!.BoundsMin, node.Mesh.BoundsMax, min, max)) continue; - triangles = TrianglesInBox(node.Mesh, min, max, out insideMin, out insideMax); + if (!Overlaps(solid.Min, solid.Max, min, max)) continue; + triangles = TrianglesInBox(solid, min, max, out insideMin, out insideMax); if (triangles == 0) continue; // its box reaches in; its geometry does not } else if (position is not { } p || !Overlaps(p, p, min, max)) @@ -238,28 +239,71 @@ public IReadOnlyList Find( found.Add(new SceneObjectInfo( node.Name, node.Kind, PathOf(node), position is { } at ? [at.X, at.Y, at.Z] : null, - bounded ? [node.Mesh!.BoundsMin.X, node.Mesh.BoundsMin.Y, node.Mesh.BoundsMin.Z] : null, - bounded ? [node.Mesh!.BoundsMax.X, node.Mesh.BoundsMax.Y, node.Mesh.BoundsMax.Z] : null, + shape is { } b0 ? [b0.Min.X, b0.Min.Y, b0.Min.Z] : null, + shape is { } b1 ? [b1.Max.X, b1.Max.Y, b1.Max.Z] : null, node.IsSelected, triangles, triangles > 0 ? [insideMin.X, insideMin.Y, insideMin.Z] : null, triangles > 0 ? [insideMax.X, insideMax.Y, insideMax.Z] : null, - node.Mesh?.PickPositions?.Length, - node.Mesh?.PickIndices?.Length / 3)); + shape?.Positions?.Length ?? node.Mesh?.PickPositions?.Length, + (shape?.Indices?.Length ?? node.Mesh?.PickIndices?.Length) / 3)); } return found; } + /// The triangles a row occupies and their world-space bounds. Positions are null for a mesh that + /// keeps no CPU geometry — its bounds still stand. + private readonly record struct Shape(Vector3[]? Positions, uint[]? Indices, Matrix4x4 World, Vector3 Min, Vector3 Max); + + // Decoded hulls, by the cooked bytes they came from: a re-cooked hull is another array and is decoded again. + private static readonly System.Runtime.CompilerServices.ConditionalWeakTable> Hulls = new(); + + private static Shape? ShapeOf(SceneNode node) + { + if (node.Mesh is { Instanced: false } mesh) + { + return new Shape(mesh.PickPositions, mesh.PickIndices, mesh.World, mesh.BoundsMin, mesh.BoundsMax); + } + if (node.Source is not Assets.Adapters.CollisionInstanceAdapter placement) return null; + + byte[]? cooked = placement.Document.Collision.Meshes.FirstOrDefault(m => m.Hash == placement.Instance.Hash)?.CookedMesh; + if (cooked == null) return null; + Tuple hull = Hulls.GetValue(cooked, static bytes => + { + try + { + Formats.Collisions.CookedTriangleMesh decoded = Formats.Collisions.CookedTriangleMesh.Decode(bytes); + return Tuple.Create(decoded.Vertices, Array.ConvertAll(decoded.Triangles, i => (uint)i)); + } + catch (Formats.Collisions.CollisionDecodeException) + { + return Tuple.Create(Array.Empty(), Array.Empty()); + } + }); + if (hull.Item1.Length == 0) return null; + + Matrix4x4 world = placement.WorldTransform; + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + foreach (Vector3 vertex in hull.Item1) + { + Vector3 at = Vector3.Transform(vertex, world); + min = Vector3.Min(min, at); + max = Vector3.Max(max, at); + } + return new Shape(hull.Item1, hull.Item2, world, min, max); + } + /// How many of a mesh's triangles reach into a world-space box, and the extent of those /// triangles clipped to it. Null when the mesh keeps no CPU geometry to ask. private static int? TrianglesInBox( - Rendering.Gpu.GpuMesh mesh, Vector3 boxMin, Vector3 boxMax, out Vector3 insideMin, out Vector3 insideMax) + Shape shape, Vector3 boxMin, Vector3 boxMax, out Vector3 insideMin, out Vector3 insideMax) { insideMin = new Vector3(float.MaxValue); insideMax = new Vector3(float.MinValue); - if (mesh.PickPositions is not { } positions || mesh.PickIndices is not { } indices) return null; + if (shape.Positions is not { } positions || shape.Indices is not { } indices) return null; - Matrix4x4 world = mesh.World; + Matrix4x4 world = shape.World; int count = 0; for (int i = 0; i + 2 < indices.Length; i += 3) { From 4771cbb379ff8a86fd4e46f5074646a11181f2fa Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:50:58 +0300 Subject: [PATCH 041/102] perf(bridge): read a level's index width without decoding the mesh Review of #3. HasWideIndices decoded the whole level - every vertex channel, the index array and, on a model, the skin - to read one field of its index buffer, on every push, ahead of a path that decodes the mesh again. It now clamps the level and looks at the index buffer directly. --probe-bridge-rebuild 17/17, --probe-bridge-vertex 2/2, --probe-bridge-resplit 6/6. --- src/Illusion.Assets/Bridge/BridgeMeshApplier.cs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs b/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs index 5663458..5a19565 100644 --- a/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs +++ b/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs @@ -1083,10 +1083,12 @@ private static (Vector3 Min, Vector3 Max) SlotAabb( return (min, max); } + // Read off the level's index buffer and nothing else. This runs on every push, ahead of a path that + // decodes the mesh anyway — asking a full decode (every vertex channel unpacked, the skin with it) just + // to learn the index width paid for the whole mesh twice. private static bool HasWideIndices(IFrameNode node, int lod) => - node is FrameNodeAdapter { Frame: FrameObjectSingleMesh frame } - && SdsMeshLoader.DecodeLod(frame, lod) is { } decoded - && frame.GetIndexBuffer(decoded.Lod) is { IndexFormat: 2 }; + node is FrameNodeAdapter { Frame: FrameObjectSingleMesh { Geometry.LOD.Length: > 0 } frame } + && frame.GetIndexBuffer(SdsMeshLoader.ClampLod(frame, lod)) is { IndexFormat: 2 }; private static bool FaceSetMatches(DecodedMesh decoded, MeshObjectPayload payload, out string? reason) { From f12155bd3f9b4d24dcfbe141c15098a7477d8155 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 20:50:59 +0300 Subject: [PATCH 042/102] fix(mcp): editor tools say no when the editor does, and find crash copies from the table Review of #3, the findings in the MCP session. editor_open_area dropped unsaved edits. Changing the area or the season reloads the scene, which clears the edited frames, the unsaved list and the undo history, and the tool only checked for a Blender session. A load that would replace a scene with unsaved edits is now refused unless the caller passes discardUnsavedEdits=true; asking for the area and season already shown stays a no-op. editor_save and editor_build reported success after a save that did not complete. The viewport's save does not throw for a material library that would not write or a car's working copy that was refused - it posts a notice and carries on - so the tool had nothing to return but success, and a build went on to pack archives whose materials were not on disk. D3DImageHost.SaveEditsReport hands back what was left unsaved; editor_save answers success=false with notSaved, and a build whose save did not complete packs nothing and says why. object_set_property could not rename. Base.Name is offered as an editable HashName and the parser had no case for it. It has one now, with the property panel's rule (an empty name is refused), and a rename to the name the object already has is not committed as an edit. Numeric properties took NaN and infinity. float.TryParse accepts "NaN", "Infinity" and "1e100" (as infinity); a float and each component of a vector now have to be finite. scene_find did not see crash copies until their row had been expanded in the tree, and a box query never returned one. They are now searched from the placement table: by the copy's own label or its prop's name, and by the prop's triangles at the copy's matrix. Only the copies returned get a tree node. --probe-editor-tools (new, no game install needed) drives the real tool methods against a scripted editor and the parser directly: 23 checks. Checked live in the editor on uppertown: 5 lamp copies found with no row expanded, 288 triangles of one in a box round it, none 400 m above it; a moved mesh blocks a load of another area and of the other season, the same area is a no-op, discardUnsavedEdits=true loads. A failing material write was not reproduced live - that path is covered by the scripted editor only. --- src/Illusion.Mcp/EditorModels.cs | 6 +- src/Illusion.Mcp/IEditorSession.cs | 16 +- src/Illusion.Mcp/Tools/EditorTools.cs | 39 +++- src/Illusion/Diagnostics/ProbeRunner.cs | 6 + .../Diagnostics/Probes/EditorToolProbes.cs | 216 ++++++++++++++++++ src/Illusion/Mcp/AppEditorSession.cs | 159 ++++++++++++- src/Illusion/Viewport/D3DImageHost.cs | 26 ++- src/Illusion/Viewport/DistrictStreamer.cs | 29 +++ src/Illusion/Viewport/ScenePersistence.cs | 4 +- 9 files changed, 472 insertions(+), 29 deletions(-) create mode 100644 src/Illusion/Diagnostics/Probes/EditorToolProbes.cs diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index 10baa9c..f1d0020 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -42,10 +42,12 @@ public sealed record ObjectProperty(string Group, string Id, string Label, strin public sealed record EditorNotice(DateTime Time, bool Error, string Text); /// What a Build wrote: each packed archive with the backup taken of what it replaced, and each -/// archive that failed with the reason. +/// archive that failed with the reason. is what the save a build starts with +/// could not write; when it is not empty the build stopped there and packed nothing. public sealed record BuildOutcome( IReadOnlyList<(string Archive, string? Backup)> Packed, - IReadOnlyList<(string Archive, string Error)> Failed); + IReadOnlyList<(string Archive, string Error)> Failed, + IReadOnlyList NotSaved); /// Where the viewport camera is. Yaw and pitch are in radians, as the camera keeps them. public sealed record CameraInfo(float[] Position, float Yaw, float Pitch, float OrbitDistance); diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index f58418c..fc5c8e5 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -24,8 +24,10 @@ public interface IEditorSession /// Opens the map editor from the launcher when it is not open yet. Null on success. string? EnsureEditor(); - /// Starts loading an area (a no-op when it is the one already shown). Null on success. - string? LoadArea(string area, bool winter); + /// Starts loading an area (a no-op when it is the one already shown). Null on success. A load + /// that would replace a scene with unsaved edits is refused unless + /// says they may be lost. + string? LoadArea(string area, bool winter, bool discardUnsavedEdits); /// Scene-tree rows by name fragment, kind and/or a world-space box their bounds or position /// must touch. @@ -48,10 +50,14 @@ public interface IEditorSession /// The most recent notices, oldest first. IReadOnlyList Notices(int last); - /// Writes every unsaved edit to the working copies. Null on success. - string? Save(out int filesWritten); + /// Writes every unsaved edit to the working copies. Null when the save ran — which is not the + /// same as everything being written: names each thing the save had to leave + /// unsaved (a material library that would not write, a working copy that was refused), and is empty only + /// when the save is complete. + string? Save(out int filesWritten, out IReadOnlyList notSaved); - /// Saves, then packs every archive with edits, keeping a backup of each. + /// Saves, then packs every archive with edits, keeping a backup of each. When the save does not + /// complete nothing is packed, and says what stood in the way. BuildOutcome Build(); CameraInfo Camera(); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index c3d8dcf..6f8c905 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -47,13 +47,14 @@ public static async Task ListAreas(IEditorSession editor, IUiThreadMarsh } [McpServerTool(Name = "editor_open_area")] - [Description("Open the map editor if it is still on the launcher, load an area and wait until it has finished streaming in. Returns the editor status. Refuses while a Blender edit session is open.")] + [Description("Open the map editor if it is still on the launcher, load an area and wait until it has finished streaming in. Returns the editor status. Refuses while a Blender edit session is open, and refuses to replace a scene that has unsaved edits (editor_save first, or pass discardUnsavedEdits=true); asking for the area and season already shown is always a no-op.")] public static async Task OpenArea( IEditorSession editor, IUiThreadMarshal ui, [Description("Area name as editor_list_areas spells it, e.g. 'uppertown'.")] string area, [Description("Load the winter variant (_z archives). Default false.")] bool winter = false, - [Description("How long to wait for the area to load, in seconds. Default 180.")] int timeoutSeconds = 180) + [Description("How long to wait for the area to load, in seconds. Default 180.")] int timeoutSeconds = 180, + [Description("Load even though the scene has unsaved edits — they and the undo history are lost. Default false.")] bool discardUnsavedEdits = false) { try { @@ -67,7 +68,8 @@ public static async Task OpenArea( await Task.Delay(Poll); } - if (await ui.RunAsync(() => editor.LoadArea(area, winter)) is { } refused) return ToolResult.Invalid(refused); + if (await ui.RunAsync(() => editor.LoadArea(area, winter, discardUnsavedEdits)) is { } refused) + return ToolResult.Invalid(refused); deadline = DateTime.UtcNow.AddSeconds(Math.Max(5, timeoutSeconds)); DateTime quietSince = DateTime.UtcNow; @@ -95,7 +97,7 @@ public static async Task OpenArea( } [McpServerTool(Name = "scene_find")] - [Description("Find objects in the loaded scene by a fragment of their name, by kind, and/or by a world-space box. Returns name, kind, tree path, position and bounds. With a box, a mesh is returned only when its TRIANGLES reach into the box (not merely its bounds), with TrianglesInBox and the extent of those triangles clipped to the box — the way to check that a volume is free before building in it. Objects without a mesh match a box by their position. Use it also to learn exact names before scene_select.")] + [Description("Find objects in the loaded scene by a fragment of their name, by kind, and/or by a world-space box. Returns name, kind, tree path, position and bounds. With a box, a mesh is returned only when its TRIANGLES reach into the box (not merely its bounds), with TrianglesInBox and the extent of those triangles clipped to the box — the way to check that a volume is free before building in it. Objects without a mesh match a box by their position. Crash-layer copies (kind CrashInstance: trees, lamps, bins) are searched from the placement table whether or not their rows are expanded in the tree; a copy matches a name by its own label or by its prop's name, and a box by its prop's triangles at the copy's placement. Use it also to learn exact names before scene_select.")] public static async Task Find( IEditorSession editor, IUiThreadMarshal ui, @@ -238,16 +240,26 @@ public static async Task Notices( } [McpServerTool(Name = "editor_save")] - [Description("Save every unsaved edit into the working copies (the editor's Ctrl+S): scene documents, collisions and material libraries. Does not touch the game's archives — editor_build does that.")] + [Description("Save every unsaved edit into the working copies (the editor's Ctrl+S): scene documents, collisions and material libraries. Does not touch the game's archives — editor_build does that. success is false, with notSaved naming each one, when something could not be written (a material library, a refused working copy); what could be written still was.")] public static async Task Save(IEditorSession editor, IUiThreadMarshal ui) { try { int files = 0; - string? failed = await ui.RunAsync(() => editor.Save(out files)); - return failed != null - ? ToolResult.Invalid(failed) - : ToolResult.Json(new { success = true, filesWritten = files, status = await ui.RunAsync(editor.Status) }); + IReadOnlyList notSaved = []; + string? failed = await ui.RunAsync(() => editor.Save(out files, out notSaved)); + if (failed != null) return ToolResult.Invalid(failed); + EditorStatus status = await ui.RunAsync(editor.Status); + return notSaved.Count == 0 + ? ToolResult.Json(new { success = true, filesWritten = files, status }) + : ToolResult.Json(new + { + success = false, + error = "the save did not complete — see notSaved", + filesWritten = files, + notSaved, + status, + }); } catch (Exception ex) { @@ -262,6 +274,15 @@ public static async Task Build(IEditorSession editor, IUiThreadMarshal u try { BuildOutcome outcome = await ui.RunAsync(editor.Build); + if (outcome.NotSaved.Count > 0) + { + return ToolResult.Json(new + { + success = false, + error = "the save a build starts with did not complete, so no archive was packed — see notSaved", + notSaved = outcome.NotSaved, + }); + } if (outcome.Packed.Count == 0 && outcome.Failed.Count == 0) return ToolResult.Invalid("no edits to build — nothing has been edited in this editor session"); return ToolResult.Json(new diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 7c63c54..0b8e349 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -630,6 +630,12 @@ public static bool TryRun(string[] args) case "--probe-mcp": McpProbes.RunMcpProbe(); return true; + // The MCP editor tools against a scripted editor: what a save, a build and an area load tell + // the caller when the editor could not do it, and which property values are refused. No game + // install needed. Output: %TEMP%\illusion_editor_tools.txt + case "--probe-editor-tools": + EditorToolProbes.RunEditorToolProbe(); + return true; // Material editor: preview sphere generator, MTL catalog browse/edit/create/delete (in-memory // only), SetTextureFor file roundtrip on a TEMP copy of default.mtl, mesh-slot reassignment, // and the tile grid + editor window layout. Output: %TEMP%\illusion_material_editor.txt diff --git a/src/Illusion/Diagnostics/Probes/EditorToolProbes.cs b/src/Illusion/Diagnostics/Probes/EditorToolProbes.cs new file mode 100644 index 0000000..250e851 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/EditorToolProbes.cs @@ -0,0 +1,216 @@ +using System.IO; +using System.Numerics; +using System.Reflection; +using System.Text; +using System.Text.Json; +using Illusion.Domain.Properties; +using Illusion.Mcp; +using Illusion.Mcp.Tools; + +namespace Illusion.Diagnostics.Probes; + +/// +/// The MCP editor tools where they can be wrong without an editor on screen: what they tell the caller when +/// the editor could not do what was asked, and what they refuse to hand it. +/// +/// A tool's answer is the only thing its caller sees — it cannot read the notice bar — so "the editor said no" +/// has to arrive as "no". The editor here is a scripted stand-in behind : each +/// case says what the editor would report and checks the JSON that comes out of the real tool method. +/// +/// No game install is needed and nothing is written. Output: %TEMP%\illusion_editor_tools.txt +/// +internal static class EditorToolProbes +{ + internal static void RunEditorToolProbe() + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_editor_tools.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)}"); + } + + try + { + // ── values a property may be given ── + PropertyDescriptor number = Descriptor(PropertyKind.Float, 1f); + foreach (string bad in new[] { "NaN", "Infinity", "-Infinity", "1e100", "" }) + { + Check($"a float property refuses \"{bad}\"", !AppEditorSession.TryParse(number, bad, out _)); + } + Check("…and takes an ordinary number", + AppEditorSession.TryParse(number, "42.5", out object? taken) && taken is 42.5f); + + PropertyDescriptor vector = Descriptor(PropertyKind.Vector3, Vector3.Zero); + foreach (string bad in new[] { "NaN, 0, 0", "0, Infinity, 0", "0, 0, 1e100", "1, 2" }) + { + Check($"a vector property refuses \"{bad}\"", !AppEditorSession.TryParse(vector, bad, out _)); + } + Check("…and takes three ordinary numbers", + AppEditorSession.TryParse(vector, "1, -2.5, 3", out taken) && taken is Vector3 v && v == new Vector3(1f, -2.5f, 3f)); + + PropertyDescriptor name = Descriptor(PropertyKind.HashName, new HashNameValue(0x1234, "old_name")); + Check("a name property can be written — it is offered as editable, so it has to be", + AppEditorSession.TryParse(name, "renamed_mesh", out taken) + && taken is HashNameValue renamed && renamed.Name == "renamed_mesh" && renamed.Hash == 0, + taken?.ToString() ?? "refused"); + Check("…but not with an empty name, which would keep the old hash", + !AppEditorSession.TryParse(name, "", out _) && !AppEditorSession.TryParse(name, " ", out _)); + + // ── what a save and a build say when the editor could not write everything ── + var marshal = new InlineMarshal(); + var status = new EditorStatus(true, "uppertown", false, false, 100, [], true, [], 0, "Render"); + + (IEditorSession clean, FakeSession.Script cleanEditor) = FakeSession.Create(status); + cleanEditor.SaveWrites = 3; + using (JsonDocument saved = Run(EditorTools.Save(clean, marshal))) + { + Check("a save that wrote everything reports success", + saved.RootElement.GetProperty("success").GetBoolean() + && saved.RootElement.GetProperty("filesWritten").GetInt32() == 3, saved.RootElement.ToString()); + } + + (IEditorSession partial, FakeSession.Script partialEditor) = FakeSession.Create(status); + partialEditor.SaveWrites = 1; + partialEditor.NotSaved = ["material libraries: default.mtl is in use by another process"]; + using (JsonDocument saved = Run(EditorTools.Save(partial, marshal))) + { + JsonElement root = saved.RootElement; + Check("a save that left a material library unwritten does NOT report success", + !root.GetProperty("success").GetBoolean(), root.ToString()); + Check("…it names what was not saved", + root.TryGetProperty("notSaved", out JsonElement left) && left.GetArrayLength() == 1 + && left[0].GetString()!.Contains("default.mtl", StringComparison.Ordinal)); + Check("…and still says what it did write", + root.TryGetProperty("filesWritten", out JsonElement files) && files.GetInt32() == 1); + } + + (IEditorSession blocked, FakeSession.Script blockedEditor) = FakeSession.Create(status); + blockedEditor.BuildResult = new BuildOutcome([], [], ["material libraries: default.mtl is in use by another process"]); + using (JsonDocument built = Run(EditorTools.Build(blocked, marshal))) + { + JsonElement root = built.RootElement; + Check("a build whose save did not complete does NOT report success", + !root.GetProperty("success").GetBoolean(), root.ToString()); + Check("…and says the save is why, not that there was nothing to build", + root.TryGetProperty("notSaved", out JsonElement left) && left.GetArrayLength() == 1 + && !root.ToString().Contains("no edits to build", StringComparison.Ordinal)); + } + + (IEditorSession packed, FakeSession.Script packedEditor) = FakeSession.Create(status); + packedEditor.BuildResult = new BuildOutcome([("uppertown.sds", "uppertown_backup.sds")], [], []); + using (JsonDocument built = Run(EditorTools.Build(packed, marshal))) + { + Check("a build that packed its archives reports success", + built.RootElement.GetProperty("success").GetBoolean() + && built.RootElement.GetProperty("packed").GetArrayLength() == 1, built.RootElement.ToString()); + } + + // ── an area load the editor refused ── + (IEditorSession dirty, FakeSession.Script dirtyEditor) = FakeSession.Create(status); + dirtyEditor.LoadRefusal = "the scene holds unsaved edits"; + using (JsonDocument opened = Run(EditorTools.OpenArea(dirty, marshal, "greenfield"))) + { + Check("an area load the editor refused comes back as a refusal, with the reason", + !opened.RootElement.GetProperty("success").GetBoolean() + && opened.RootElement.ToString().Contains("unsaved edits", StringComparison.Ordinal), + opened.RootElement.ToString()); + } + Check("…and the tool passed on that the caller did not ask to discard them", + dirtyEditor.LastDiscard == false); + using (JsonDocument _ = Run(EditorTools.OpenArea(dirty, marshal, "greenfield", discardUnsavedEdits: true))) + { + Check("…while discardUnsavedEdits=true reaches the editor as given", dirtyEditor.LastDiscard == true); + } + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + sb.Insert(0, $"EDITOR TOOLS PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static PropertyDescriptor Descriptor(PropertyKind kind, object value) => new() + { + Id = "Probe." + kind, + Label = kind.ToString(), + Kind = kind, + Get = () => value, + Set = _ => { }, + }; + + // Probes run before the dispatcher loop starts, so a tool's task is driven from the thread pool — the + // same reason --probe-mcp does it. + private static JsonDocument Run(Task tool) => + JsonDocument.Parse(Task.Run(() => tool).GetAwaiter().GetResult()); + + private sealed class InlineMarshal : IUiThreadMarshal + { + public Task RunAsync(Func work) => Task.FromResult(work()); + + public Task RunAsync(Action work) + { + work(); + return Task.CompletedTask; + } + } + + /// + /// An editor that answers from a script. A proxy rather than a class implementing the interface: the + /// session has dozens of members and these cases care about four of them, and a hand-written stand-in + /// would have to be edited every time a tool is added. + /// + internal class FakeSession : DispatchProxy + { + internal sealed class Script + { + internal EditorStatus Status = null!; + internal int SaveWrites; + internal IReadOnlyList NotSaved = []; + internal BuildOutcome BuildResult = new([], [], []); + internal string? LoadRefusal; + internal bool? LastDiscard; + } + + private Script _script = new(); + + internal static (IEditorSession Session, Script Script) Create(EditorStatus status) + { + IEditorSession session = Create(); + var proxy = (FakeSession)(object)session; + proxy._script.Status = status; + return (session, proxy._script); + } + + protected override object? Invoke(MethodInfo? targetMethod, object?[]? args) + { + switch (targetMethod?.Name) + { + case nameof(IEditorSession.Status): + return _script.Status; + case nameof(IEditorSession.EnsureEditor): + return null; + case nameof(IEditorSession.Areas): + return (IReadOnlyList)["uppertown", "greenfield"]; + case nameof(IEditorSession.LoadArea): + _script.LastDiscard = (bool)args![2]!; + return _script.LoadRefusal; + case nameof(IEditorSession.Save): + args![0] = _script.SaveWrites; + args[1] = _script.NotSaved; + return null; + case nameof(IEditorSession.Build): + return _script.BuildResult; + default: + throw new NotSupportedException(targetMethod?.Name + " is not scripted in this probe"); + } + } + } +} diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 4b50a71..1eaed53 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -71,7 +71,7 @@ public IReadOnlyList Areas() => : "neither the launcher nor the map editor is open"; } - public string? LoadArea(string area, bool winter) + public string? LoadArea(string area, bool winter, bool discardUnsavedEdits) { if (Window is not { } window) return NotOpen; D3DImageHost host = window.Viewport; @@ -82,6 +82,18 @@ public IReadOnlyList Areas() => if (target == null) return $"no area named '{area}' — editor_list_areas has the names"; if (winter && target.Winter == null) return $"'{target.BaseName}' has no winter variant"; + // A reload resets the scene: the edited frames, the list of what is unsaved and the undo history all + // go with it, and nothing says so. Asking for the area already on screen reloads nothing and is + // always allowed; anything else has to be told that the edits may go. + bool reloads = window.WholeMapCheck.IsChecked == true + || (window.WinterToggle.IsChecked == true) != winter + || !ReferenceEquals(window.AreaCombo.SelectedItem, target); + if (reloads && host.HasUnsavedEdits && !discardUnsavedEdits) + { + return "the scene holds unsaved edits, and loading another area or season drops them together " + + "with the undo history — editor_save first, or pass discardUnsavedEdits=true to give them up"; + } + // Each control reloads the scene when it changes and only then, so an area that is already the one // shown costs nothing here. The season goes first: the area selector's reload then reads it. if (window.WholeMapCheck.IsChecked == true) window.WholeMapCheck.IsChecked = false; @@ -101,6 +113,9 @@ public IReadOnlyList Find( foreach (SceneNode node in AllNodes(host)) { if (found.Count >= limit) break; + // A crash copy only has a tree node once it has been clicked or its row expanded, so the tree + // cannot answer for the copies — they are searched from the placement data below. + if (node.Kind == CrashCopyKind) continue; if (nameContains != null && !node.Name.Contains(nameContains, StringComparison.OrdinalIgnoreCase)) continue; if (kind != null && !string.Equals(node.Kind, kind, StringComparison.OrdinalIgnoreCase)) continue; @@ -135,9 +150,108 @@ public IReadOnlyList Find( node.Mesh?.PickPositions?.Length, node.Mesh?.PickIndices?.Length / 3)); } + + if (kind == null || string.Equals(kind, CrashCopyKind, StringComparison.OrdinalIgnoreCase)) + FindCrashCopies(host, nameContains, lo, hi, limit, found); return found; } + private const string CrashCopyKind = "CrashInstance"; + + /// + /// The crash layer's copies — trees, lamps, bins: 57 000 of them in the shipped city — found from the + /// placement table itself. A copy matches a name by its own label ("copy #id") or by the prop it is a + /// copy of, and a box by its prototype's TRIANGLES stood at the copy's matrix, the same test a mesh gets: + /// "is this volume free" is asked of what is drawn, and a lamp post is drawn well away from its origin. + /// Only the copies that are returned are given a tree node (which is what a later select names). + /// + private static void FindCrashCopies( + D3DImageHost host, string? nameContains, Vector3? lo, Vector3? hi, int limit, List found) + { + foreach ((Formats.Translokator.Object row, IReadOnlyList<(Rendering.Gpu.GpuMesh Mesh, Matrix4x4 Local)> prototypes) + in host.Streamer.CrashRows()) + { + bool propNamed = nameContains == null + || row.Name.String.Contains(nameContains, StringComparison.OrdinalIgnoreCase); + foreach (Formats.Translokator.Instance copy in row.Instances) + { + if (found.Count >= limit) return; + if (!propNamed && !$"copy #{copy.ID}".Contains(nameContains!, StringComparison.OrdinalIgnoreCase)) continue; + + int? triangles = null; + Vector3 insideMin = default, insideMax = default; + if (lo is { } min && hi is { } max) + { + if (prototypes.Count == 0) + { + // Nothing to test but where it stands (its prototype keeps no CPU geometry). + if (!Overlaps(copy.Position, copy.Position, min, max)) continue; + } + else + { + Matrix4x4 placed = DistrictStreamer.CrashWorld(copy); + int count = 0; + insideMin = new Vector3(float.MaxValue); + insideMax = new Vector3(float.MinValue); + foreach ((Rendering.Gpu.GpuMesh mesh, Matrix4x4 local) in prototypes) + { + count += TrianglesInBox(mesh, local * placed, min, max, ref insideMin, ref insideMax); + } + if (count == 0) continue; + triangles = count; + } + } + + if (host.Streamer.CrashNodeFor(copy, row) is not { } node) continue; + found.Add(new SceneObjectInfo( + node.Name, node.Kind, PathOf(node), + [copy.Position.X, copy.Position.Y, copy.Position.Z], + null, null, + node.IsSelected, + triangles, + triangles > 0 ? [insideMin.X, insideMin.Y, insideMin.Z] : null, + triangles > 0 ? [insideMax.X, insideMax.Y, insideMax.Z] : null)); + } + } + } + + // A prototype's triangles at one copy's matrix against a box, the copy's own box asked first: a query + // walks every copy in the city, and all but a handful are nowhere near. + private static int TrianglesInBox( + Rendering.Gpu.GpuMesh mesh, Matrix4x4 world, Vector3 boxMin, Vector3 boxMax, + ref Vector3 insideMin, ref Vector3 insideMax) + { + if (mesh.PickPositions is not { } positions || mesh.PickIndices is not { } indices) return 0; + + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + for (int k = 0; k < 8; k++) + { + Vector3 corner = Vector3.Transform( + new Vector3( + (k & 1) == 0 ? mesh.LocalMin.X : mesh.LocalMax.X, + (k & 2) == 0 ? mesh.LocalMin.Y : mesh.LocalMax.Y, + (k & 4) == 0 ? mesh.LocalMin.Z : mesh.LocalMax.Z), + world); + min = Vector3.Min(min, corner); + max = Vector3.Max(max, corner); + } + if (!Overlaps(min, max, boxMin, boxMax)) return 0; + + int count = 0; + for (int i = 0; i + 2 < indices.Length; i += 3) + { + Vector3 a = Vector3.Transform(positions[indices[i]], world); + Vector3 b = Vector3.Transform(positions[indices[i + 1]], world); + Vector3 c = Vector3.Transform(positions[indices[i + 2]], world); + if (!TriangleBoxTest.Overlaps(a, b, c, boxMin, boxMax)) continue; + count++; + insideMin = Vector3.Min(insideMin, Vector3.Max(boxMin, Vector3.Min(a, Vector3.Min(b, c)))); + insideMax = Vector3.Max(insideMax, Vector3.Min(boxMax, Vector3.Max(a, Vector3.Max(b, c)))); + } + return count; + } + /// How many of a mesh's triangles reach into a world-space box, and the extent of those /// triangles clipped to it. Null when the mesh keeps no CPU geometry to ask. private static int? TrianglesInBox( @@ -206,13 +320,16 @@ public IReadOnlyList Find( public IReadOnlyList Notices(int last) => EditorNoticeLog.Last(last); - public string? Save(out int filesWritten) + public string? Save(out int filesWritten, out IReadOnlyList notSaved) { filesWritten = 0; + notSaved = []; if (Window is not { } window) return NotOpen; try { - filesWritten = window.Viewport.SaveEdits(); + D3DImageHost.SaveReport report = window.Viewport.SaveEditsReport(); + filesWritten = report.Written; + notSaved = report.NotSaved; return null; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException) @@ -226,11 +343,17 @@ public BuildOutcome Build() D3DImageHost host = Host; // The viewport's own save, not only the frame documents the build re-saves: a material library // edited in this session has to be on disk before the archive that names it is packed. - host.SaveEdits(); + // + // And when that save does not complete, nothing is packed: an archive packed now would ship meshes + // naming materials that are not in the library on disk, with a backup taken and "built" reported. + D3DImageHost.SaveReport saved = host.SaveEditsReport(); + if (!saved.Complete) return new BuildOutcome([], [], saved.NotSaved); + D3DImageHost.BuildReport report = host.BuildEdits(createBackup: true); return new BuildOutcome( report.Packed.Select(p => (p.Archive, p.Backup)).ToList(), - report.Failed.Select(f => (f.Archive, f.Error)).ToList()); + report.Failed.Select(f => (f.Archive, f.Error)).ToList(), + []); } public CameraInfo Camera() @@ -408,6 +531,15 @@ public IReadOnlyList Properties(string name) if (property == null) return $"'{name}' has no property '{propertyId}' — object_properties lists them"; if (property.IsReadOnly || property.Set == null) return $"'{property.Label}' is read-only"; if (!TryParse(property, value, out object? parsed)) return $"'{value}' is not a {property.Kind} value"; + // A name is boxed with a zero hash on the way in (the adapter derives the real one), so the generic + // "did it change" comparison cannot see an unchanged name — and renaming a frame to its own name + // would still land on the undo stack as an edit. + if (parsed is Domain.Properties.HashNameValue renamed + && property.Get() is Domain.Properties.HashNameValue current + && string.Equals(current.Name, renamed.Name, StringComparison.Ordinal)) + { + return null; + } host.CommitPropertyEdit(node, property, property.Get(), parsed); return null; } @@ -429,7 +561,9 @@ private static string Show(Domain.Properties.PropertyDescriptor p) }; } - private static bool TryParse(Domain.Properties.PropertyDescriptor p, string text, out object? value) + /// Turns the text a tool was handed into the boxed value a property takes — or refuses it. + /// Internal so the refusals can be checked without an editor on screen (--probe-editor-tools). + internal static bool TryParse(Domain.Properties.PropertyDescriptor p, string text, out object? value) { IFormatProvider inv = System.Globalization.CultureInfo.InvariantCulture; const System.Globalization.NumberStyles Num = System.Globalization.NumberStyles.Float; @@ -441,7 +575,9 @@ private static bool TryParse(Domain.Properties.PropertyDescriptor p, string text value = n; return true; case Domain.Properties.PropertyKind.Float: - if (!float.TryParse(text, Num, inv, out float f)) return false; + // "NaN" and "Infinity" parse, and so does "1e100" — as infinity. None of them is a draw + // distance or a light's range, and a setter would write them into the file as given. + if (!float.TryParse(text, Num, inv, out float f) || !float.IsFinite(f)) return false; value = f; return true; case Domain.Properties.PropertyKind.Bool: @@ -451,6 +587,12 @@ private static bool TryParse(Domain.Properties.PropertyDescriptor p, string text case Domain.Properties.PropertyKind.Text: value = text; return true; + case Domain.Properties.PropertyKind.HashName: + // The same rule as the property panel: an empty name would keep the hash of the old one. + // The hash is the adapter's to derive, so it travels as zero. + if (string.IsNullOrWhiteSpace(text)) return false; + value = new Domain.Properties.HashNameValue(0, text); + return true; case Domain.Properties.PropertyKind.UInt64Hex: string hex = text.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? text[2..] : text; if (!ulong.TryParse(hex, System.Globalization.NumberStyles.HexNumber, inv, out ulong h)) return false; @@ -459,7 +601,8 @@ private static bool TryParse(Domain.Properties.PropertyDescriptor p, string text case Domain.Properties.PropertyKind.Vector3: string[] parts = text.Split(',', StringSplitOptions.TrimEntries); if (parts.Length != 3 || !float.TryParse(parts[0], Num, inv, out float x) - || !float.TryParse(parts[1], Num, inv, out float y) || !float.TryParse(parts[2], Num, inv, out float z)) + || !float.TryParse(parts[1], Num, inv, out float y) || !float.TryParse(parts[2], Num, inv, out float z) + || !float.IsFinite(x) || !float.IsFinite(y) || !float.IsFinite(z)) { return false; } diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index 55637c0..21cc42b 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -718,15 +718,33 @@ internal void RaisePushLanded(IReadOnlyList movedBones) /// Raised when the unsaved/edited state may have changed, so the title '*' and menus can refresh. public event Action? DirtyChanged; + /// What a save did: how many files it wrote, and what it could NOT write. A save does not + /// throw for a material library that would not write or a working copy that was refused — it says so + /// and carries on with the rest — so "it returned" is not "everything is on disk". + public readonly record struct SaveReport(int Written, IReadOnlyList NotSaved) + { + /// Everything that was unsaved is now on disk. + public bool Complete => NotSaved.Count == 0; + } + /// Writes the edited frame documents AND every dirty MTL library (each .mtl gets a timestamped /// backup + atomic replace, like an .sds build). An MTL failure is reported through the notice surface — /// not thrown — so the frame save always completes. - public int SaveEdits() + public int SaveEdits() => SaveEditsReport().Written; + + /// , with what was left unsaved handed back as well as shown: a caller + /// that goes on to pack archives, or reports to something that cannot see the notice bar, has to know. + public SaveReport SaveEditsReport() { + var notSaved = new List(); string? materialError = MaterialEditing.SaveDirtyMaterials(out int savedLibraries); - int saved = Persistence.SaveEdits(); - if (materialError != null) RaiseNotice("Materials not saved: " + materialError, isError: true); - return saved + savedLibraries; + int saved = Persistence.SaveEdits(notSaved); + if (materialError != null) + { + RaiseNotice("Materials not saved: " + materialError, isError: true); + notSaved.Insert(0, "material libraries: " + materialError); + } + return new SaveReport(saved + savedLibraries, notSaved); } /// diff --git a/src/Illusion/Viewport/DistrictStreamer.cs b/src/Illusion/Viewport/DistrictStreamer.cs index e987c64..ef797d5 100644 --- a/src/Illusion/Viewport/DistrictStreamer.cs +++ b/src/Illusion/Viewport/DistrictStreamer.cs @@ -574,6 +574,35 @@ private void LiveUpdateCrash() } } + /// + /// One row of the loaded crash layer with the geometry its copies are drawn from: each prototype mesh that + /// keeps CPU geometry, with the matrix that stands it inside a copy. Enumerated from the placement DATA — + /// the tree only holds a node for a copy somebody has clicked or whose row was expanded, so anything that + /// has to answer for every loaded copy cannot go by the tree. + /// + public IEnumerable<(Formats.Translokator.Object Row, IReadOnlyList<(GpuMesh Mesh, Matrix4x4 Local)> Prototypes)> + CrashRows() + { + foreach (CrashSource src in _crashSources) + { + foreach (Formats.Translokator.Object row in src.Placements.Rows) + { + var prototypes = new List<(GpuMesh, Matrix4x4)>(); + foreach (FrameObjectSingleMesh mesh in src.Placements.MeshesOf(row)) + { + if (!src.Leaves.TryGetValue(mesh, out SceneNode? leaf)) continue; + if (leaf.Mesh is not { PickPositions: not null, PickIndices: not null } gm) continue; + prototypes.Add((gm, src.Placements.LocalOf(mesh, row))); + } + yield return (row, prototypes); + } + } + } + + /// Where one crash copy stands in the world — the matrix its prototype is drawn at. + public static Matrix4x4 CrashWorld(Instance copy) => + TransformMath.Compose(copy.Quaternion, new Vector3(copy.Scale), copy.Position); + // Ray-picks the nearest crash placement under a viewport ray (CPU). The props are drawn hardware-instanced, // so the ordinary GpuMesh pick cannot reach a single copy — it would have to stand for the whole cloud. This // tests the ray against the prototype's own geometry at each copy's matrix instead, and resolves the hit back diff --git a/src/Illusion/Viewport/ScenePersistence.cs b/src/Illusion/Viewport/ScenePersistence.cs index f73f1b5..52b64ed 100644 --- a/src/Illusion/Viewport/ScenePersistence.cs +++ b/src/Illusion/Viewport/ScenePersistence.cs @@ -99,7 +99,8 @@ public void Reset() /// Writes every edited-but-unsaved scene document back into its extracted folder. Frames that leave /// the scene mid-session are skipped defensively. Returns the number of archives written; clears the '*'. - public int SaveEdits() + /// Takes one line per document that was refused and so is still unsaved. + public int SaveEdits(List? notSaved = null) { int saved = 0; foreach (SceneNode fr in _unsavedFrames.ToList()) @@ -124,6 +125,7 @@ public int SaveEdits() { _host.RaiseNotice( "this car's working copy was not written: " + why, isError: true); + notSaved?.Add($"{document.SourceArchive.Name}: {why}"); continue; } _unsavedFrames.Remove(fr); // written; the archive stays on the build list until packed From 36966306279609064d1ba63b62eb508c3d2a13c9 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:02:40 +0300 Subject: [PATCH 043/102] fix(mirror): the winter mirror decides against the pair as shipped, not against the last mirror Review of #5, the four findings in SeasonMirror. Twins were told by size. Two archives passed as "one scene shipped twice" when their shared pools were equal and their scenes the same length - and a winter scene with one object moved 50 units is the same length. The scenes are now parsed, winter's material hashes replaced by summer's, and the two written back out: anything left that differs (a transform, a parent link, a draw distance) means winter is a scene of its own and the mirror is refused. The 13 stock twins still read as twins and the other ten as not. Everything else follows from reading that pair once (SeasonPair) and deciding against it, where the mirror used to ask the winter WORKING COPY what winter wore - which answers with whatever the previous mirror left: - A slot the modder re-pointed in summer got its old winter material back. A summer material no object of that name wore when the pair shipped is now carried into winter as it is (Report.Reassigned). - Namesakes were matched by position among objects of the same name, so deleting the first handed its materials to the second. The season's substitution is now looked up by name and SUMMER MATERIAL; position is only used when one summer material stands for two winter ones under one name, and only while the number of namesakes is what it shipped as. Otherwise the object keeps summer's material and is counted (Report.Ambiguous, raised as an error notice) rather than dressed in another object's snow. - A texture an earlier mirror had brought was never refreshed: the bridge repaints an authored texture in place, and "winter has a file of that name" ended the matter. A file winter did not SHIP with is now compared and rewritten, its MIP_ companion with it (and removed when the new picture has none); a file winter shipped with is the season's own and is left alone. Mirror() also refuses a winter folder that is not made of the pair's meshes before writing anything, and editor_mirror_winter stops when the save it starts with did not complete. Its result names slotsReassigned and objectsAmbiguous in place of objectsReshaped. --probe-season-mirror builds its own namesakes and runs every case above on scratch copies: 23/23 on uppertown and oysterbay, 22/22 on italy, 21/21 on greenfield (not a twin, so the shipped-texture checks do not apply). --- src/Illusion.Assets/Sds/SeasonMirror.cs | 374 +++++++++++++++--- src/Illusion.Mcp/EditorModels.cs | 11 +- src/Illusion.Mcp/Tools/EditorTools.cs | 7 +- .../Diagnostics/Probes/SeasonMirrorProbes.cs | 259 +++++++++++- src/Illusion/Mcp/AppEditorSession.cs | 17 +- 5 files changed, 570 insertions(+), 98 deletions(-) diff --git a/src/Illusion.Assets/Sds/SeasonMirror.cs b/src/Illusion.Assets/Sds/SeasonMirror.cs index cf49b93..842ad11 100644 --- a/src/Illusion.Assets/Sds/SeasonMirror.cs +++ b/src/Illusion.Assets/Sds/SeasonMirror.cs @@ -23,14 +23,14 @@ namespace Illusion.Assets.Sds; /// ten — eastside, greenfield, hunters, kingstone, midtown, port, sandisland, seagift, southport, westside — /// have a winter scene of its own: other buffers, another name table, other collisions. Writing summer's /// files over one of those would replace what winter is. So the pair is checked first, on the archives as -/// they were before this toolkit built either (), and a district that fails -/// is refused. +/// they were before this toolkit built either (), and a district that fails is +/// refused. /// /// /// That is what this does, on the two extracted working copies: the summer scene is written over the winter -/// one with each object's materials taken from the winter object of the same name; the files the editor -/// edits are copied across; and the textures the result names that winter does not have are added. Nothing -/// is packed — the caller puts the winter archive on the build list. +/// one with the materials the two seasons differ in put back; the files the editor edits are copied across; +/// and the textures the result names are added, or refreshed where a mirror brought them before. Nothing is +/// packed — the caller puts the winter archive on the build list. /// /// public static class SeasonMirror @@ -43,14 +43,198 @@ public static class SeasonMirror ]; /// What a mirror did, for the notice. - /// Winter objects whose materials were kept on the summer object of the same name. - /// Summer objects winter did not have — they arrive as they are. - /// Winter meshes summer no longer has — they are gone from winter too. - /// Matched objects whose material slots no longer line up; they keep summer's. + /// Objects every one of whose materials was settled: winter's own where the season + /// changes it, summer's where it was deliberately changed. + /// Summer objects under a name the shipped pair does not have — they arrive as they are. + /// Meshes the shipped pair has and summer no longer does — gone from winter too. + /// Material slots the modder pointed at another material in summer; the new + /// material is carried into winter rather than overwritten with the old winter one. + /// Objects that could not be told apart from a namesake wearing the same summer + /// material and another winter one (one of them was deleted or added). They keep summer's material — + /// taking the namesake's would dress them in another object's snow — and are worth a look. /// Working-copy files that were written, by name. - /// Textures added to the winter archive. + /// Textures added to the winter archive or refreshed in it. public sealed record Report( - int Matched, int Added, int Dropped, int Reshaped, IReadOnlyList Files, IReadOnlyList Textures); + int Matched, int Added, int Dropped, int Reassigned, int Ambiguous, + IReadOnlyList Files, IReadOnlyList Textures); + + /// + /// What the two seasons of one district are to each other, read off the pair as it SHIPPED: which winter + /// material stands for which summer one, object by object, and which files winter came with. + /// + /// Everything a mirror decides is decided against this and not against the winter working copy. The + /// working copy is whatever the last mirror left there; asking it "what did winter wear here" answers + /// with the previous answer, and an object matched to it by position among its namesakes moves to another + /// object's answer the moment one of them is deleted. + /// + /// + public sealed class SeasonPair + { + // name → summer material → the winter materials that stand for it on objects of that name. + private readonly Dictionary>> _byName = []; + + // name → its objects in file order, each as (summer, winter) hashes per level and slot. Only read + // when one summer material answers to two winter ones under the same name. + private readonly Dictionary> _objects = []; + + private readonly HashSet _stockWinterFiles = new(StringComparer.OrdinalIgnoreCase); + + private SeasonPair() + { + } + + /// Meshes the shipped pair has, namesakes counted each. + public int Meshes { get; private set; } + + /// + /// Reads a pair from the two scenes as shipped. Null, with what differs, when they are not one scene + /// in two sets of materials: the same objects in the same order, and — once winter's material hashes + /// are replaced by summer's — the same bytes. Equal length proves none of that: a transform, a parent + /// link, a draw distance can all change without the scene changing size. + /// Both scenes are consumed: the winter one is rewritten in memory to make the comparison. + /// + /// The files the winter archive shipped with, by name — the textures a + /// mirror must never overwrite. + public static SeasonPair? Read( + FrameResource summer, FrameResource winter, IEnumerable stockWinterFiles, out string? difference) + { + ArgumentNullException.ThrowIfNull(summer); + ArgumentNullException.ThrowIfNull(winter); + var pair = new SeasonPair(); + foreach (string file in stockWinterFiles) pair._stockWinterFiles.Add(Path.GetFileName(file)); + + List ours = [.. summer.FrameObjects.Values.OfType()]; + List theirs = [.. winter.FrameObjects.Values.OfType()]; + if (ours.Count != theirs.Count) + { + difference = $"the scenes hold {ours.Count} and {theirs.Count} objects"; + return null; + } + + for (int i = 0; i < ours.Count; i++) + { + if (ours[i].GetType() != theirs[i].GetType() || ours[i].Name.Hash != theirs[i].Name.Hash) + { + difference = $"object {i} is '{ours[i].Name}' in one scene and '{theirs[i].Name}' in the other"; + return null; + } + FrameMaterial? a = MaterialOf(ours[i]), b = MaterialOf(theirs[i]); + if (a == null && b == null) continue; + if (a == null || b == null || !SameShape(a, b)) + { + difference = $"'{ours[i].Name}' has other material slots in winter"; + return null; + } + + ulong name = ours[i].Name.Hash; + if (!pair._byName.TryGetValue(name, out Dictionary>? map)) pair._byName[name] = map = []; + if (!pair._objects.TryGetValue(name, out List<(ulong[][], ulong[][])>? list)) pair._objects[name] = list = []; + var summerHashes = new ulong[a.Materials.Count][]; + var winterHashes = new ulong[a.Materials.Count][]; + for (int lod = 0; lod < a.Materials.Count; lod++) + { + summerHashes[lod] = [.. a.Materials[lod].Select(m => m.MaterialHash)]; + winterHashes[lod] = [.. b.Materials[lod].Select(m => m.MaterialHash)]; + for (int slot = 0; slot < summerHashes[lod].Length; slot++) + { + if (!map.TryGetValue(summerHashes[lod][slot], out HashSet? stands)) + map[summerHashes[lod][slot]] = stands = []; + stands.Add(winterHashes[lod][slot]); + } + } + list.Add((summerHashes, winterHashes)); + pair.Meshes++; + } + + // The whole test: with the materials made equal, is there anything left that differs? + for (int i = 0; i < ours.Count; i++) + { + if (MaterialOf(ours[i]) is not { } a || MaterialOf(theirs[i]) is not { } b) continue; + for (int lod = 0; lod < a.Materials.Count; lod++) + { + for (int slot = 0; slot < a.Materials[lod].Length; slot++) + { + b.Materials[lod][slot].MaterialHash = a.Materials[lod][slot].MaterialHash; + } + } + } + byte[] summerBytes = summer.WriteToStream(), winterBytes = winter.WriteToStream(); + if (!summerBytes.AsSpan().SequenceEqual(winterBytes)) + { + int at = 0; + int shared = Math.Min(summerBytes.Length, winterBytes.Length); + while (at < shared && summerBytes[at] == winterBytes[at]) at++; + difference = "the two scenes differ in more than their materials — an object is placed, parented " + + $"or set up differently in winter (first at byte {at} of the scene)"; + return null; + } + + difference = null; + return pair; + } + + /// Whether the shipped pair has a mesh of this name. + public bool Knows(ulong name) => _byName.ContainsKey(name); + + /// How many meshes of this name the shipped pair has. + public int CountOf(ulong name) => _objects.TryGetValue(name, out List<(ulong[][], ulong[][])>? list) ? list.Count : 0; + + /// Whether winter shipped with a file of this name. The top level of a split texture is a + /// file of its own in a working copy (MIP_name.dds) and travels in the archive under the + /// texture's own name, so it shipped if its texture did. + public bool ShippedInWinter(string file) + { + string name = Path.GetFileName(file); + return _stockWinterFiles.Contains(name) + || (name.StartsWith("MIP_", StringComparison.OrdinalIgnoreCase) && _stockWinterFiles.Contains(name[4..])); + } + + internal enum Answer + { + /// The season changes this material: winter is what stands for it. + Seasonal, + + /// No object of this name wore this material when the pair shipped — the modder put it + /// there, and it goes to winter as it is. + Reassigned, + + /// Namesakes wear this material in summer and different ones in winter, and which of + /// them this object is can no longer be told. + Ambiguous, + } + + /// What a slot of an edited summer object becomes in winter. + /// Which of the objects of this name it is, in file order. + /// How many objects of this name the edited scene has. + internal Answer Resolve( + ulong name, int occurrence, int namesakes, int lod, int slot, ulong summerHash, out ulong winter) + { + winter = summerHash; + if (!_byName.TryGetValue(name, out Dictionary>? map) + || !map.TryGetValue(summerHash, out HashSet? stands)) + { + return Answer.Reassigned; + } + if (stands.Count == 1) + { + winter = stands.First(); + return Answer.Seasonal; + } + + // One summer material, several winter ones under this name. Position among the namesakes is the + // only thing left to go by, and it only means what it meant when the pair shipped if none of them + // has come or gone — and if the object at that position really wore this material in this slot. + List<(ulong[][] Summer, ulong[][] Winter)> list = _objects[name]; + if (namesakes == list.Count && occurrence < list.Count + && lod < list[occurrence].Summer.Length && slot < list[occurrence].Summer[lod].Length + && list[occurrence].Summer[lod][slot] == summerHash) + { + winter = list[occurrence].Winter[lod][slot]; + return Answer.Seasonal; + } + return Answer.Ambiguous; + } + } /// /// Mirrors 's working copy into 's. Null with a reason @@ -69,23 +253,27 @@ public sealed record Report( reason = $"{summer.Name} has no working copy — open and save it first"; return null; } - if (!ArePristineTwins(summer, winter, out string? difference)) + if (ReadPristinePair(summer, winter, out string? difference) is not { } pair) { reason = $"{winter.Name} is not {summer.Name} with snow on it — as shipped, {difference}. Mirroring " + "would write the summer files over a winter scene of its own; edit that archive directly"; return null; } - return Mirror(summerDir, SdsMeshLoader.EnsureExtracted(winter), summer.Name, winter.Name, out reason); + return Mirror(summerDir, SdsMeshLoader.EnsureExtracted(winter), summer.Name, winter.Name, pair, out reason); } /// /// Whether the two archives were one scene shipped twice BEFORE either was edited: the same shared - /// resources byte for byte and a frame resource of the same length. Judged on each archive's oldest - /// backup — the build keeps every version, so the first one is the archive as it shipped — or on the - /// archive itself when it has never been built. + /// resources byte for byte, and scenes that differ in nothing but the materials their objects wear. + /// Judged on each archive's oldest backup — the build keeps every version, so the first one is the + /// archive as it shipped — or on the archive itself when it has never been built. /// /// What differs, when they are not twins. - public static bool ArePristineTwins(FileInfo summer, FileInfo winter, out string? difference) + public static bool ArePristineTwins(FileInfo summer, FileInfo winter, out string? difference) => + ReadPristinePair(summer, winter, out difference) != null; + + /// The shipped pair of two archives, or null with what differs when they are not twins. + public static SeasonPair? ReadPristinePair(FileInfo summer, FileInfo winter, out string? difference) { ArgumentNullException.ThrowIfNull(summer); ArgumentNullException.ThrowIfNull(winter); @@ -113,9 +301,23 @@ public static bool ArePristineTwins(FileInfo summer, FileInfo winter, out string { differing.Add("the two scenes are not the same size"); } + if (differing.Count > 0) + { + difference = string.Join(", ", differing); + return null; + } - difference = differing.Count == 0 ? null : string.Join(", ", differing); - return differing.Count == 0; + // The cheap tests passed; now the one that says the scenes are the same scene. Sizes agreeing is + // where this used to stop, and a winter archive with one object moved passed it. + return SeasonPair.Read(Parse(summerScenes[0]), Parse(winterScenes[0]), b.ResolveEntryNames(), out difference); + } + + private static FrameResource Parse(byte[] payload) + { + var scene = new FrameResource(); + using var stream = new MemoryStream(payload, writable: false); + scene.ReadFromFile(stream); + return scene; } private static FileInfo Pristine(FileInfo sds) @@ -137,66 +339,88 @@ private static List PayloadsOf(SdsArchive archive, string type) } /// The same over two extracted folders, named for the messages — what - /// does once it has found them, and what a probe runs on scratch copies. + /// does once it has found them and read the shipped pair, and what a probe runs on scratch copies. public static Report? Mirror(string summerDir, string winterDir, string summerName, string winterName, - out string? reason) + SeasonPair pair, out string? reason) { + ArgumentNullException.ThrowIfNull(pair); reason = null; ExtractedSds from = ExtractedSds.Load(summerDir); ExtractedSds to = ExtractedSds.Load(winterDir); - if (from.FrameResource is not { } scene || to.FrameResource is not { } snow) + if (from.FrameResource is not { } scene || to.FrameResource is not { } current) { reason = "one of the two archives carries no scene"; return null; } - // Winter's material choices, per object. Names repeat inside a scene, so an object is its name AND - // which of the objects of that name it is, in file order — the order both seasons share. - var winterMaterials = new Dictionary<(ulong Name, int Occurrence), FrameMaterial>(); - var seen = new Dictionary(); - foreach (object value in snow.FrameObjects.Values) + // The folder about to be written over has to be this pair's winter: whatever state a mirror or an + // edit left it in, it is still made of the meshes the pair shipped with. Some other archive is not, + // and this is the last point at which it can be left alone. + var standing = new Dictionary(); + foreach (FrameObjectBase frame in current.FrameObjects.Values.OfType()) + { + if (MaterialOf(frame) != null) standing[frame.Name.Hash] = standing.GetValueOrDefault(frame.Name.Hash) + 1; + } + int familiar = standing.Sum(n => Math.Min(n.Value, pair.CountOf(n.Key))); + if (pair.Meshes == 0 || familiar < pair.Meshes * 0.9) { - if (value is not FrameObjectBase frame) continue; - int occurrence = seen[frame.Name.Hash] = seen.GetValueOrDefault(frame.Name.Hash, -1) + 1; - if (MaterialOf(frame) is { } material) winterMaterials[(frame.Name.Hash, occurrence)] = material; + reason = $"{winterName} does not look like the winter twin of {summerName}: only " + + $"{familiar} of the pair's {pair.Meshes} meshes are in it"; + return null; } - int matched = 0, added = 0, reshaped = 0; - var claimed = new HashSet<(ulong, int)>(); - seen.Clear(); - foreach (object value in scene.FrameObjects.Values) + // How many objects of each name the edited scene has: position among namesakes is only trusted + // where that number is what it was when the pair shipped. + var namesakes = new Dictionary(); + foreach (FrameObjectBase frame in scene.FrameObjects.Values.OfType()) + { + if (MaterialOf(frame) != null) namesakes[frame.Name.Hash] = namesakes.GetValueOrDefault(frame.Name.Hash) + 1; + } + + int matched = 0, added = 0, reassigned = 0, ambiguous = 0, known = 0; + var seen = new Dictionary(); + foreach (FrameObjectBase frame in scene.FrameObjects.Values.OfType()) { - if (value is not FrameObjectBase frame) continue; - int occurrence = seen[frame.Name.Hash] = seen.GetValueOrDefault(frame.Name.Hash, -1) + 1; - (ulong, int) key = (frame.Name.Hash, occurrence); if (MaterialOf(frame) is not { } ours) continue; - if (!winterMaterials.TryGetValue(key, out FrameMaterial? theirs)) + ulong name = frame.Name.Hash; + int occurrence = seen[name] = seen.GetValueOrDefault(name, -1) + 1; + if (!pair.Knows(name)) { added++; continue; } - claimed.Add(key); - if (!SameShape(ours, theirs)) - { - reshaped++; - continue; - } + known++; + + bool unsure = false; for (int lod = 0; lod < ours.Materials.Count; lod++) { for (int slot = 0; slot < ours.Materials[lod].Length; slot++) { - ours.Materials[lod][slot].MaterialHash = theirs.Materials[lod][slot].MaterialHash; + MaterialStruct material = ours.Materials[lod][slot]; + switch (pair.Resolve(name, occurrence, namesakes[name], lod, slot, material.MaterialHash, out ulong winter)) + { + case SeasonPair.Answer.Seasonal: + material.MaterialHash = winter; + break; + case SeasonPair.Answer.Reassigned: + reassigned++; + break; + default: + unsure = true; + break; + } } } - matched++; + if (unsure) ambiguous++; else matched++; } // Two archives that merely share a few names are not a seasonal pair, and writing one over the other - // would destroy it. A real pair matches on everything winter has, less what summer has since deleted. - if (winterMaterials.Count == 0 || matched + reshaped < winterMaterials.Count * 0.9) + // would destroy it. A real pair still has nearly everything it shipped with, less what was deleted. + int kept = namesakes.Sum(n => Math.Min(n.Value, pair.CountOf(n.Key))); + if (pair.Meshes == 0 || kept < pair.Meshes * 0.9) { - reason = $"{winterName} does not look like the winter twin of {summerName}: only " - + $"{matched + reshaped} of its {winterMaterials.Count} meshes have a counterpart"; + reason = $"{summerName} does not look like the district {winterName} is the winter of: only " + + $"{kept} of the pair's {pair.Meshes} meshes are in it"; return null; } @@ -233,21 +457,23 @@ private static List PayloadsOf(SdsArchive archive, string type) } } - // Textures: whatever the mirrored scene's materials name that winter does not carry and summer does. - // Stock textures are already there under either season's name, so this only ever brings what was - // added in summer. + // Textures: whatever the mirrored scene's materials name that summer carries. One winter lacks is + // added. One winter HAS is left alone when winter shipped with it — the season's own picture under + // the same name — and brought up to date when an earlier mirror put it there: the bridge repaints an + // authored texture in place, so its name never changes and "winter has a file of that name" says + // nothing about whether it is still the same picture. var textures = new List(); MafiaMaterials.EnsureLoaded(); var wanted = new HashSet(StringComparer.OrdinalIgnoreCase); - foreach (object value in scene.FrameObjects.Values) + foreach (FrameObjectBase frame in scene.FrameObjects.Values.OfType()) { - if (value is not FrameObjectBase frame || MaterialOf(frame) is not { } material) continue; + if (MaterialOf(frame) is not { } material) continue; foreach (MaterialStruct[] lod in material.Materials) { foreach (MaterialStruct slot in lod) { - IMaterial? known = MafiaMaterials.Collection?.FindByHash(slot.MaterialHash); - foreach (string texture in known?.CollectTextures() ?? []) + IMaterial? library = MafiaMaterials.Collection?.FindByHash(slot.MaterialHash); + foreach (string texture in library?.CollectTextures() ?? []) { if (texture.Length > 0) wanted.Add(texture); } @@ -256,17 +482,39 @@ private static List PayloadsOf(SdsArchive archive, string type) } foreach (string texture in wanted.Order(StringComparer.OrdinalIgnoreCase)) { - if (manifest.HasFile(texture) || !from.Manifest.HasFile(texture)) continue; - if (!CopyEntry(from.Manifest, manifest, summerDir, winterDir, texture)) continue; - textures.Add(texture); + if (!from.Manifest.HasFile(texture)) continue; string companion = "MIP_" + texture; - if (from.Manifest.HasFile(companion) && !manifest.HasFile(companion)) + bool wrote = Bring(texture); + // The top level of a split texture travels with it, and goes when the new picture has none. + if (from.Manifest.HasFile(companion)) { - CopyEntry(from.Manifest, manifest, summerDir, winterDir, companion); + wrote |= Bring(companion); } + else if (manifest.HasFile(companion) && !pair.ShippedInWinter(companion) && manifest.RemoveEntry(companion)) + { + string stale = Path.Combine(winterDir, companion); + if (File.Exists(stale)) File.Delete(stale); + wrote = true; + } + if (wrote) textures.Add(texture); } - return new Report(matched, added, winterMaterials.Count - claimed.Count, reshaped, files, textures); + return new Report(matched, added, pair.Meshes - kept, reassigned, ambiguous, files, textures); + + // Adds a summer file winter lacks, or refreshes one an earlier mirror brought. True when it wrote. + bool Bring(string name) + { + if (!manifest.HasFile(name)) return CopyEntry(from.Manifest, manifest, summerDir, winterDir, name); + if (pair.ShippedInWinter(name)) return false; + + string source = Path.Combine(summerDir, name.TrimStart('/', '\\')); + string target = Path.Combine(winterDir, name.TrimStart('/', '\\')); + if (!File.Exists(source)) return false; + byte[] bytes = File.ReadAllBytes(source); + if (File.Exists(target) && File.ReadAllBytes(target).AsSpan().SequenceEqual(bytes)) return false; + AtomicFile.WriteAllBytes(target, bytes); + return true; + } } private static FrameMaterial? MaterialOf(FrameObjectBase frame) => diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index ca7b02f..6e246c1 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -49,12 +49,13 @@ public sealed record BuildOutcome( IReadOnlyList<(string Archive, string Error)> Failed, IReadOnlyList NotSaved); -/// What mirroring a district into its winter archive did: how many winter meshes kept their own -/// materials on the summer object of the same name, how many objects winter gained and lost, how many no -/// longer lined up slot for slot (they keep summer's materials), and the files and textures written into -/// the winter working copy. +/// What mirroring a district into its winter archive did: how many objects had every material +/// settled (winter's own where the season changes it), how many objects winter gained and lost, how many +/// material slots were re-pointed in summer and carried over as they are, how many objects could not be told +/// from a namesake and kept summer's materials, and the files and textures written into the winter working +/// copy. public sealed record SeasonMirrorOutcome( - string WinterArchive, int Matched, int Added, int Dropped, int Reshaped, + string WinterArchive, int Matched, int Added, int Dropped, int Reassigned, int Ambiguous, IReadOnlyList Files, IReadOnlyList Textures); /// Where the viewport camera is. Yaw and pitch are in radians, as the camera keeps them. diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 45c529b..370521d 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -299,7 +299,7 @@ public static async Task Build(IEditorSession editor, IUiThreadMarshal u } [McpServerTool(Name = "editor_mirror_winter")] - [Description("Carry the loaded district's edits into its winter archive (_z.sds). The two archives are one scene shipped twice, differing only in which materials are swapped for their snow-covered counterparts and in their textures - so this saves, writes the summer scene over the winter one with each object's winter materials kept, copies the buffers, collisions, actors and name table across, and adds the textures winter lacks. It writes the winter WORKING COPY and queues the archive; editor_build then packs it. Load the summer variant first.")] + [Description("Carry the loaded district's edits into its winter archive (_z.sds). The two archives are one scene shipped twice, differing only in which materials are swapped for their snow-covered counterparts and in their textures - so this saves, writes the summer scene over the winter one with the materials the seasons differ in put back (a slot re-pointed at another material in summer keeps the new one: slotsReassigned), copies the buffers, collisions, actors and name table across, and adds the textures winter lacks or refreshes the ones an earlier mirror brought. objectsAmbiguous counts objects that could not be told from a namesake and kept summer's materials - look at those. Refused for a district whose winter archive is a scene of its own. It writes the winter WORKING COPY and queues the archive; editor_build then packs it. Load the summer variant first.")] public static async Task MirrorWinter(IEditorSession editor, IUiThreadMarshal ui) { try @@ -314,9 +314,10 @@ public static async Task MirrorWinter(IEditorSession editor, IUiThreadMa objectsMatched = outcome.Matched, objectsAdded = outcome.Added, objectsDropped = outcome.Dropped, - objectsReshaped = outcome.Reshaped, + slotsReassigned = outcome.Reassigned, + objectsAmbiguous = outcome.Ambiguous, filesWritten = outcome.Files, - texturesAdded = outcome.Textures, + texturesWritten = outcome.Textures, status = await ui.RunAsync(editor.Status), }); } diff --git a/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs b/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs index ca25b7b..e079f67 100644 --- a/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs +++ b/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs @@ -52,6 +52,7 @@ void Check(string name, bool ok, string detail = "") // The summer side wears other materials on its first meshes — the one way the seasons differ. int recoloured = 0; ExtractedSds dressed = ExtractedSds.Load(summer); + ExtractedSds snowy = ExtractedSds.Load(winter); foreach (FrameObjectSingleMesh mesh in dressed.FrameResource!.FrameObjects.Values.OfType()) { if (recoloured == 60) break; @@ -59,32 +60,66 @@ void Check(string name, bool ok, string detail = "") foreach (Formats.Frames.Resources.MaterialStruct slot in mesh.Material.Materials[0]) slot.MaterialHash ^= 0x5EA50AUL; recoloured++; } - AtomicFile.WriteAllBytes(SdsManifest.Load(summer).GetFiles("FrameResource")[0], dressed.FrameResource.WriteToStream()); - // What the winter scene is when this toolkit writes it, untouched: the yardstick for "unchanged". + // Two groups of NAMESAKES are dressed by hand, because they are where a mirror can go wrong and a + // stock district may or may not happen to have them: + // "told apart" — two objects of one name in different summer materials, each with a winter one + // of its own (S0→W0, S1→W1); + // "look alike" — two objects of one name in the SAME summer material and different winter ones + // (S2→W2, S2→W3). + const ulong S0 = 0x1111_0000_0000_0001, S1 = 0x1111_0000_0000_0002, S2 = 0x1111_0000_0000_0003; + const ulong W0 = 0x2222_0000_0000_0001, W1 = 0x2222_0000_0000_0002, W2 = 0x2222_0000_0000_0003, W3 = 0x2222_0000_0000_0004; + // …so two pairs are MADE, the same way in both scenes: four meshes with names of their own, the + // second of each pair given the name of the first. + List> groups = MakeNamesakes(dressed.FrameResource); + Check("four meshes were found to make two pairs of namesakes from", groups.Count == 2 + && MakeNamesakes(snowy.FrameResource!).Count == 2, $"{groups.Count} pairs"); + if (groups.Count < 2) return; + SetSlot(dressed.FrameResource, groups[0][0], S0); + SetSlot(dressed.FrameResource, groups[0][1], S1); + SetSlot(snowy.FrameResource!, groups[0][0], W0); + SetSlot(snowy.FrameResource!, groups[0][1], W1); + SetSlot(dressed.FrameResource, groups[1][0], S2); + SetSlot(dressed.FrameResource, groups[1][1], S2); + SetSlot(snowy.FrameResource!, groups[1][0], W2); + SetSlot(snowy.FrameResource!, groups[1][1], W3); + string summerScene = SdsManifest.Load(summer).GetFiles("FrameResource")[0]; string winterScene = SdsManifest.Load(winter).GetFiles("FrameResource")[0]; + AtomicFile.WriteAllBytes(summerScene, dressed.FrameResource.WriteToStream()); + AtomicFile.WriteAllBytes(winterScene, snowy.FrameResource!.WriteToStream()); + byte[] summerAsShipped = File.ReadAllBytes(summerScene), winterAsShipped = File.ReadAllBytes(winterScene); + + // The pair "as shipped" is these two scratch scenes, read before anything is edited; what winter + // "shipped with" is everything its manifest lists (the textures are listed though not copied here). + string[] stock = [.. SdsManifest.Load(winter).Entries.Select(e => Path.GetFileName(e.File)).OfType()]; + SeasonMirror.SeasonPair? PairWith(IEnumerable shipped, out string? why) => + SeasonMirror.SeasonPair.Read(Parse(summerAsShipped), Parse(winterAsShipped), shipped, out why); + SeasonMirror.SeasonPair? pair = PairWith(stock, out string? pairWhy); + Check("two scenes that differ only in materials are read as a pair", pair != null, pairWhy ?? ""); + if (pair == null) return; + + // What the winter scene is when this toolkit writes it, untouched: the yardstick for "unchanged". byte[] resaved = ExtractedSds.Load(winter).FrameResource!.WriteToStream(); Dictionary before = Snapshot(winter); // ── Nothing was edited: the mirror must leave winter as it is ── - SeasonMirror.Report? idle = SeasonMirror.Mirror(summer, winter, "summer", "winter", out string? reason); + SeasonMirror.Report? idle = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out string? reason); Check("an unedited pair mirrors", idle != null, reason ?? ""); if (idle == null) return; - Check("every winter mesh found its summer object, none added, dropped or reshaped", - idle.Matched > 0 && idle.Added == 0 && idle.Dropped == 0 && idle.Reshaped == 0, - $"{idle.Matched} matched, {idle.Added} added, {idle.Dropped} dropped, {idle.Reshaped} reshaped"); - Check("the winter scene is what a plain re-save of it gives — its own materials came back", + Check("every mesh was settled: none added, dropped, re-pointed or left in doubt", + idle.Matched > 0 && idle is { Added: 0, Dropped: 0, Reassigned: 0, Ambiguous: 0 }, + $"{idle.Matched} matched, {idle.Added} added, {idle.Dropped} dropped, {idle.Reassigned} re-pointed, {idle.Ambiguous} in doubt"); + Check("the winter scene is what a plain re-save of it gives — its own materials came back, " + + "the look-alike namesakes each to their own", recoloured > 0 && File.ReadAllBytes(winterScene).AsSpan().SequenceEqual(resaved), $"{recoloured} meshes wear other materials on the summer side"); Check("no other file was written and no texture added", idle.Files.Count == 1 && idle.Textures.Count == 0 && Unchanged(winter, before, winterScene), string.Join(", ", idle.Files)); - // ── Winter is missing an object summer has: it arrives, and nothing else moves ── + // ── The winter WORKING COPY is short of an object: it is the shipped pair that is mirrored ── ExtractedSds thinned = ExtractedSds.Load(winter); FrameResource scene = thinned.FrameResource!; - // Taken from the far end of the list, clear of the meshes whose summer materials were changed: the - // object comes back wearing what summer wears, and for this one that is what winter wore too. FrameObjectSingleMesh? victim = scene.FrameObjects.Values.OfType() .LastOrDefault(m => m.GetType() == typeof(FrameObjectSingleMesh) && m.Children.Count == 0 && scene.FrameObjects.Values.OfType().Count(o => o.Name.Hash == m.Name.Hash) == 1); @@ -94,17 +129,128 @@ void Check(string name, bool ok, string detail = "") scene.DeleteFrame(victim); AtomicFile.WriteAllBytes(winterScene, scene.WriteToStream()); - SeasonMirror.Report? healed = SeasonMirror.Mirror(summer, winter, "summer", "winter", out reason); - Check("a winter archive short of one object mirrors", healed != null, reason ?? ""); + SeasonMirror.Report? healed = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out reason); + Check("a winter working copy short of one object mirrors", healed != null, reason ?? ""); if (healed == null) return; - Check("the object summer has and winter lacked is reported as added", - healed.Added == 1 && healed.Dropped == 0 && healed.Matched == idle.Matched - 1, - $"'{victim.Name}': {healed.Matched} matched, {healed.Added} added"); Check("and winter is whole again, byte for byte the scene it was", File.ReadAllBytes(winterScene).AsSpan().SequenceEqual(resaved)); - SeasonMirror.Report? again = SeasonMirror.Mirror(summer, winter, "summer", "winter", out reason); - Check("a second mirror finds nothing left to add", - again is { Added: 0, Dropped: 0, Reshaped: 0 } && again.Matched == idle.Matched, reason ?? ""); + + // ── Review of #5, P1: equal size is not "the same scene" ── + // One object of the winter archive stands 50 units away. Nothing about the file's size or its + // pools changes; the pair must still be refused, or the mirror would put it back where summer has it. + { + FrameResource asSummer = ExtractedSds.Load(summer).FrameResource!; + FrameResource moved = ExtractedSds.Load(winter).FrameResource!; + FrameObjectSingleMesh shifted = moved.FrameObjects.Values.OfType().First(); + System.Numerics.Matrix4x4 local = shifted.LocalTransform; + local.M41 += 50f; + shifted.LocalTransform = local; + int sizeBefore = ExtractedSds.Load(winter).FrameResource!.WriteToStream().Length; + int sizeAfter = moved.WriteToStream().Length; + SeasonMirror.SeasonPair? notTwins = SeasonMirror.SeasonPair.Read(asSummer, moved, stock, out string? movedWhy); + Check("a winter scene with one object moved is NOT a twin, though it is the same size", + notTwins == null && sizeBefore == sizeAfter, $"{sizeBefore} vs {sizeAfter} bytes — {movedWhy}"); + } + + // ── Review of #5: a material deliberately changed in summer goes to winter ── + { + ExtractedSds edited = ExtractedSds.Load(summer); + const ulong Chosen = 0x7777_0000_0000_0001; + List meshes = [.. edited.FrameResource!.FrameObjects.Values.OfType() + .Where(m => m.Refs.ContainsKey(FrameEntryRefTypes.Material) && m.Material.Materials is { Count: > 0 } all && all[0].Length > 0)]; + int at = meshes.Count / 2; + ulong name = meshes[at].Name.Hash; + meshes[at].Material.Materials[0][0].MaterialHash = Chosen; + AtomicFile.WriteAllBytes(summerScene, edited.FrameResource.WriteToStream()); + + SeasonMirror.Report? carried = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out reason); + List inWinter = [.. ExtractedSds.Load(winter).FrameResource!.FrameObjects.Values.OfType() + .Where(m => m.Refs.ContainsKey(FrameEntryRefTypes.Material) && m.Material.Materials is { Count: > 0 } all && all[0].Length > 0)]; + Check("a slot re-pointed at another material in summer keeps the new material in winter", + carried != null && inWinter.Count == meshes.Count && inWinter[at].Name.Hash == name + && inWinter[at].Material.Materials[0][0].MaterialHash == Chosen, + carried == null ? reason ?? "" : $"0x{inWinter[at].Material.Materials[0][0].MaterialHash:X16}"); + Check("…and is reported as re-pointed, with nothing else disturbed", + carried is { Reassigned: 1, Ambiguous: 0, Added: 0, Dropped: 0 }, + carried == null ? "" : $"{carried.Reassigned} re-pointed, {carried.Ambiguous} in doubt"); + AtomicFile.WriteAllBytes(summerScene, summerAsShipped); + } + + // ── Review of #5: a namesake deleted — the survivor must not inherit its materials ── + { + ExtractedSds edited = ExtractedSds.Load(summer); + FrameResource cut = edited.FrameResource!; + List all = [.. cut.FrameObjects.Values.OfType()]; + foreach (int first in new[] { groups[0][0], groups[1][0] }) + { + FrameObjectBase gone = all[first]; + foreach (Formats.Frames.Resources.FrameHeaderScene folder in cut.FrameScenes.Values) folder.Children.Remove(gone); + cut.DeleteFrame(gone); + } + ulong toldApart = all[groups[0][1]].Name.Hash, lookAlike = all[groups[1][1]].Name.Hash; + AtomicFile.WriteAllBytes(summerScene, cut.WriteToStream()); + + SeasonMirror.Report? afterDelete = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out reason); + Check("a scene with two namesakes deleted mirrors", afterDelete != null, reason ?? ""); + if (afterDelete != null) + { + FrameResource result = ExtractedSds.Load(winter).FrameResource!; + ulong SlotOf(ulong name) => result.FrameObjects.Values.OfType() + .Where(m => m.Name.Hash == name && m.Refs.ContainsKey(FrameEntryRefTypes.Material)) + .Select(m => m.Material.Materials[0][0].MaterialHash).FirstOrDefault(); + Check("the survivor of two namesakes in different materials wears ITS OWN winter material", + SlotOf(toldApart) == W1, $"0x{SlotOf(toldApart):X16} (its own is W1, the deleted one's W0)"); + Check("the survivor of two look-alikes is not given the deleted one's snow: it keeps summer's", + SlotOf(lookAlike) == S2, $"0x{SlotOf(lookAlike):X16}"); + Check("…and is reported as in doubt, so the modder knows to look", + afterDelete is { Ambiguous: 1, Dropped: 2 }, + $"{afterDelete.Ambiguous} in doubt, {afterDelete.Dropped} dropped"); + } + AtomicFile.WriteAllBytes(summerScene, summerAsShipped); + } + + // ── Review of #5: an authored texture repainted after an earlier mirror ── + // The bridge repaints a texture in place, so the second mirror finds a file of that name in + // winter already. It has to be brought up to date — unless winter SHIPPED with it, in which case + // it is the season's own picture and stays. + { + MafiaMaterials.EnsureLoaded(); + string? texture = null; + foreach (FrameObjectSingleMesh mesh in ExtractedSds.Load(summer).FrameResource!.FrameObjects.Values.OfType()) + { + if (!mesh.Refs.ContainsKey(FrameEntryRefTypes.Material)) continue; + foreach (Formats.Frames.Resources.MaterialStruct slot in mesh.Material.Materials.SelectMany(l => l)) + { + texture ??= (MafiaMaterials.Collection?.FindByHash(slot.MaterialHash)?.CollectTextures() ?? []) + .FirstOrDefault(t => t.Length > 0 && SdsManifest.Load(summer).HasFile(t) && SdsManifest.Load(winter).HasFile(t)); + } + if (texture != null) break; + } + Check("a texture both seasons list was found to repaint", texture != null); + if (texture != null) + { + byte[] painted = [1, 2, 3, 4, 5], repainted = [9, 8, 7, 6, 5, 4]; + File.WriteAllBytes(Path.Combine(winter, texture), painted); // what an earlier mirror left + File.WriteAllBytes(Path.Combine(summer, texture), repainted); // what the bridge wrote since + + SeasonMirror.SeasonPair? authoredPair = PairWith( + stock.Where(f => !f.Equals(texture, StringComparison.OrdinalIgnoreCase)), out _); + SeasonMirror.Report? refreshed = authoredPair == null ? null + : SeasonMirror.Mirror(summer, winter, "summer", "winter", authoredPair, out reason); + Check("a texture an earlier mirror brought is refreshed when summer's has changed", + refreshed != null && File.ReadAllBytes(Path.Combine(winter, texture)).AsSpan().SequenceEqual(repainted) + && refreshed.Textures.Contains(texture, StringComparer.OrdinalIgnoreCase), + refreshed == null ? reason ?? "no pair" : string.Join(", ", refreshed.Textures)); + + File.WriteAllBytes(Path.Combine(winter, texture), painted); + SeasonMirror.Report? kept = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out reason); + Check("a texture winter shipped with is left as winter has it", + kept != null && File.ReadAllBytes(Path.Combine(winter, texture)).AsSpan().SequenceEqual(painted) + && kept.Textures.Count == 0, kept == null ? reason ?? "" : string.Join(", ", kept.Textures)); + File.Delete(Path.Combine(winter, texture)); + File.Delete(Path.Combine(summer, texture)); + } + } // ── Which of the install's pairs ARE one scene shipped twice ── // Reported, not asserted against a fixed list: the answer is read off each archive's oldest backup, @@ -112,17 +258,43 @@ void Check(string name, bool ok, string detail = "") // The stock answer is 13 of 23. What IS asserted is that the check tells the two kinds apart. var twins = new List(); var others = new List(); + SeasonMirror.SeasonPair? shipped = null; foreach (string z in Directory.GetFiles(MafiaEnvironment.CityFolder, "*_z.sds")) { string name = Path.GetFileNameWithoutExtension(z)[..^2]; var pairSummer = new FileInfo(Path.Combine(MafiaEnvironment.CityFolder, name + ".sds")); if (!pairSummer.Exists) continue; - (SeasonMirror.ArePristineTwins(pairSummer, new FileInfo(z), out _) ? twins : others).Add(name); + SeasonMirror.SeasonPair? read = SeasonMirror.ReadPristinePair(pairSummer, new FileInfo(z), out string? why); + (read != null ? twins : others).Add(read != null ? name : $"{name} ({Short(why)})"); + if (read != null && name.Equals(district, StringComparison.OrdinalIgnoreCase)) shipped = read; } sb.AppendLine($" twins ({twins.Count}): {string.Join(", ", twins)}"); - sb.AppendLine($" a winter scene of their own ({others.Count}): {string.Join(", ", others)}"); + sb.AppendLine($" a winter scene of their own ({others.Count}): {string.Join("; ", others)}"); Check("the install has pairs of both kinds, and the check tells them apart", twins.Count > 0 && others.Count > 0, $"{twins.Count} twins, {others.Count} not"); + if (shipped != null) + { + // The shipped winter archive has to name its textures the way the working copy does, or + // "winter shipped with this file" would never be true and every stock texture would be at + // risk. Asked from the archive's side: a working copy that has been built on holds textures + // of its own as well, and those are exactly the ones that must NOT be recognised. + IReadOnlyList backups = SdsWriter.ListBackups(winterSds); + SdsArchive asShipped = SdsArchive.Open((backups.Count == 0 ? winterSds : backups[^1].File).FullName); + string[] inArchive = [.. asShipped.ResolveEntryNames() + .Where(n => n.EndsWith(".dds", StringComparison.OrdinalIgnoreCase)).Distinct(StringComparer.OrdinalIgnoreCase)]; + SdsManifest working = SdsManifest.Load(source); + int spelledAlike = inArchive.Count(working.HasFile); + string[] listed = [.. working.Entries.Select(e => e.File) + .Where(f => f.EndsWith(".dds", StringComparison.OrdinalIgnoreCase))]; + int recognised = listed.Count(shipped.ShippedInWinter); + Check("the shipped winter archive names its textures the way the working copy does", + inArchive.Length > 0 && spelledAlike >= inArchive.Length * 0.95, + $"{spelledAlike} of the archive's {inArchive.Length} textures are in the working copy by that name; " + + $"{recognised} of the working copy's {listed.Length} texture files shipped with winter, " + + $"{listed.Length - recognised} were brought since"); + Check("every shipped texture, and the top level split off it, is recognised as shipped", + inArchive.All(shipped.ShippedInWinter) && inArchive.All(n => shipped.ShippedInWinter("MIP_" + n))); + } // ── Two archives that are not a pair are refused before anything is written ── string other = Directory.GetFiles(MafiaEnvironment.CityFolder, "*.sds") @@ -136,7 +308,7 @@ void Check(string name, bool ok, string detail = "") { string stranger = CopyWithoutTextures(other, Path.Combine(scratch, "stranger")); Dictionary strangerBefore = Snapshot(stranger); - SeasonMirror.Report? refused = SeasonMirror.Mirror(summer, stranger, "summer", "stranger", out reason); + SeasonMirror.Report? refused = SeasonMirror.Mirror(summer, stranger, "summer", "stranger", pair, out reason); Check("an archive that is not the winter twin is refused, untouched", refused == null && reason != null && Unchanged(stranger, strangerBefore, null), reason ?? ""); } @@ -155,6 +327,51 @@ void Check(string name, bool ok, string detail = "") } } + // Two pairs of namesakes, made from four meshes that can be dressed and deleted independently: plain + // childless meshes, each with a name and a material block nothing else uses. The second of each pair + // takes the first one's name. Indices are positions among the scene's frame objects, in file order — + // the same in both seasons, since the two scratch scenes are one scene. + private static List> MakeNamesakes(FrameResource scene) + { + List all = [.. scene.FrameObjects.Values.OfType()]; + Dictionary names = all.GroupBy(o => o.Name.Hash).ToDictionary(g => g.Key, g => g.Count()); + Dictionary blocks = all.OfType() + .Where(m => m.Refs.ContainsKey(FrameEntryRefTypes.Material)) + .GroupBy(m => m.Refs[FrameEntryRefTypes.Material]).ToDictionary(g => g.Key, g => g.Count()); + var picked = new List(); + // From the far end of the list, clear of the meshes whose summer materials were changed above. + for (int i = all.Count - 1; i >= 0 && picked.Count < 4; i--) + { + if (all[i] is not FrameObjectSingleMesh m || m.GetType() != typeof(FrameObjectSingleMesh)) continue; + if (m.Children.Count != 0 || names[m.Name.Hash] != 1) continue; + if (!m.Refs.TryGetValue(FrameEntryRefTypes.Material, out int block) || blocks[block] != 1) continue; + if (m.Material.Materials is not { Count: > 0 } levels || levels[0].Length == 0) continue; + picked.Add(i); + } + if (picked.Count < 4) return []; + picked.Sort(); + all[picked[1]].Name = all[picked[0]].Name; + all[picked[3]].Name = all[picked[2]].Name; + return [[picked[0], picked[1]], [picked[2], picked[3]]]; + } + + private static FrameResource Parse(byte[] scene) + { + var resource = new FrameResource(); + using var stream = new MemoryStream(scene, writable: false); + resource.ReadFromFile(stream); + return resource; + } + + private static void SetSlot(FrameResource scene, int index, ulong hash) + { + var mesh = (FrameObjectSingleMesh)scene.FrameObjects.Values.OfType().ElementAt(index); + mesh.Material.Materials[0][0].MaterialHash = hash; + } + + private static string Short(string? why) => + why == null ? "" : why.Length > 70 ? why[..70] + "…" : why; + // The mirror reads the scene, the pools and the manifest; the textures are hundreds of megabytes it only // ever copies by name, so the scratch copy leaves them where they are. private static string CopyWithoutTextures(string from, string to) diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index d82ecb9..6585056 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -374,19 +374,24 @@ public BuildOutcome Build() try { - // The mirror reads the working copy on disk, so what is only in memory has to be written first. - host.SaveEdits(); + // The mirror reads the working copy on disk, so what is only in memory has to be written first — + // and when that could not be written, the mirror would carry yesterday's scene into winter. + D3DImageHost.SaveReport saved = host.SaveEditsReport(); + if (!saved.Complete) return "the save a mirror starts with did not complete: " + string.Join("; ", saved.NotSaved); Assets.Sds.SeasonMirror.Report? report = Assets.Sds.SeasonMirror.ToWinter(area.Summer, area.Winter, out string? reason); if (report == null) return reason ?? "the winter archive could not be written"; host.MarkArchiveModified(area.Winter); outcome = new SeasonMirrorOutcome(area.Winter.FullName, report.Matched, report.Added, report.Dropped, - report.Reshaped, report.Files, report.Textures); + report.Reassigned, report.Ambiguous, report.Files, report.Textures); host.RaiseNotice( - $"Mirrored {area.BaseName} into {area.Winter.Name}: {report.Matched} object(s) kept their winter " - + $"materials, {report.Added} added, {report.Dropped} dropped, {report.Files.Count} file(s) and " - + $"{report.Textures.Count} texture(s) written — Build packs it", isError: false); + $"Mirrored {area.BaseName} into {area.Winter.Name}: {report.Matched} object(s) settled, " + + $"{report.Added} added, {report.Dropped} dropped, {report.Reassigned} re-pointed slot(s) carried over, " + + $"{report.Files.Count} file(s) and {report.Textures.Count} texture(s) written — Build packs it" + + (report.Ambiguous == 0 ? "" : $". {report.Ambiguous} object(s) could not be told from a namesake " + + "and kept their summer materials — check them in winter"), + isError: report.Ambiguous > 0); return null; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidOperationException From 838c69a0a7cb9757fe252e974d62b5e7fc0ecc5b Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:14:34 +0300 Subject: [PATCH 044/102] fix(bridge): a material push that can be undone, refused cleanly and shared across archives Review of #4, all ten findings. Texture files are part of the push's undo. A repaint under the same name changes no binding, so the catalog recorded nothing and Ctrl+Z could not bring the old picture back. The resolver now journals every file it writes (what the name held before, what it holds now) and the push carries a TextureFilesEdit in its one history entry: undo restores the old files - or removes a texture the push introduced, manifest entries included - and redo puts the new ones back. Nothing is written before everything is checked. The diffuse texture was on disk before the normal map had been looked at, and before the library had accepted the material. All images are now read, validated and packed first; what is written before a later file or the library refuses is rolled back from the same journal. A split texture is compared as two files. PickName compared only the entry, which for a texture of 256 and up starts at half resolution: a fine checkerboard and the grey it averages to are byte-identical there (reproduced), so the second picture took the first one's name and its write replaced the first one's top level. The MIP_ companion - and whether there is one - is now part of the comparison. A material shared by two archives is packed into both. The second object of a push binds the new material by name, and an unchanged material arrives with no pixels; either way an archive that had never held its textures got none. The files are now copied from the archive that has them (only for materials this bridge created - a stock material bound by name copies nothing). A renamed material keeps its hash across a shader change. Gaining or losing a normal map deletes and recreates the record, and it was recreated under the Blender datablock's current name - a new hash, with every other mesh left pointing at the old one. It is rebuilt under the name the library knows it by. Reloads are coalesced. A push of a hundred objects sharing a material asked for a hundred invalidate-and-rebind passes over every loaded mesh; each texture is now invalidated once and each material that names one rebinds once (ReloadTextureFiles). A rebound part returns the textures it held. GpuMesh kept every lease until dispose, so the library's retired copy of each repainted texture stayed on the GPU for as long as the mesh was loaded. Leases are kept per part and the superseded ones released once the part holds the new ones. The mesh preview keeps the material's tint. Add-on: a Normal Map node in Object or World space is no longer exported as if it were tangent space - the material is refused by name with what to change - and the resampler clamps its two neighbours independently: upsampling [0, 1, 0] to four texels gave 0.875 at the left edge, now 0. --probe-bridge-material 58/58 on eastside (30 new checks: each case above, including twenty repaints of a texture a live GpuMesh draws leaving nothing retired), --probe-material-editor 65/65, --probe-bridge-rebuild 17/17, --probe-mcp 66/66. The undo entry itself was checked at the file level (restore before/after), not by pressing Ctrl+Z in the editor, and the two add-on changes were not run inside Blender: the resampler was checked on the reviewer's numbers outside it. --- .../Bridge/AuthoredMaterialResolver.cs | 160 ++++++++-- .../Textures/ArchiveTextureWriter.cs | 68 ++++- .../Payload/MeshPayloadCodec.cs | 7 + src/Illusion.Rendering/Gpu/GpuMesh.cs | 49 ++- .../Textures/TextureLibrary.cs | 7 + .../addons/illusion_bridge/exporter.py | 8 + .../addons/illusion_bridge/materials.py | 29 +- .../Bridge/BridgeSessionController.cs | 7 +- .../Probes/BridgeMaterialProbes.cs | 280 ++++++++++++++++++ .../Viewport/MaterialEditController.cs | 33 ++- .../Viewport/MaterialPreviewViewport.cs | 4 +- src/Illusion/Viewport/TextureFilesEdit.cs | 55 ++++ 12 files changed, 659 insertions(+), 48 deletions(-) create mode 100644 src/Illusion/Viewport/TextureFilesEdit.cs diff --git a/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs b/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs index 7f2bdbe..2aba6b4 100644 --- a/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs +++ b/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs @@ -49,9 +49,21 @@ public AuthoredMaterialResolver(ExchangeContainer container, IAuthoredMaterialHo public List Resolved { get; } = new(); /// Materials whose texture file was rewritten under the SAME name — nothing in the material - /// changed, so whoever draws it has to be told to load the file again. + /// changed, so whoever draws it has to be told to load the file again. Each pair once, however many + /// objects of the push wear the material. public List<(ulong Hash, string Texture)> Rewritten { get; } = new(); + /// + /// One texture name this push changed in one archive's folder: what was there before and what is there + /// now. The catalog's history does not see these — a repaint under the same name changes no binding — so + /// they are the push's own to undo, and the only record from which a refused material is put back. + /// + public sealed record TextureChange( + string Dir, string File, ArchiveTextureWriter.TextureState Before, ArchiveTextureWriter.TextureState After); + + /// Every texture file this push wrote, in the order it wrote them. + public List TextureChanges { get; } = new(); + /// Archives that gained or changed a texture and therefore need a rebuild. public Dictionary TouchedArchives { get; } = new(StringComparer.OrdinalIgnoreCase); @@ -68,6 +80,16 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s bool hasHash = BridgeMeshApplier.TryParseMaterialHash(info.Hash, out ulong hash) && hash != 0; string name = info.Name?.Trim() ?? ""; + // A normal map in object or world space cannot become the game's: its channels are directions + // in another frame, and packed as tangent-space X and Y they light the surface wrongly with + // nothing to say why. Refused by name, before anything of the material is written. + if (info.NormalSpace is { Length: > 0 } space && !space.Equals("TANGENT", StringComparison.OrdinalIgnoreCase)) + { + skipReason = $"material '{name}': its Normal Map node is in {Friendly(space)} space, and the game reads " + + "tangent-space normal maps only — set the node's Space to Tangent (or bake the map to tangent space)"; + return false; + } + // Blender remembers a material this bridge created, but the library may not: the creation was // undone, or the toolkit closed without a Save. With the pixels at hand it is simply made again; // without them (the addon only sends images that changed) the datablock is told to forget, so @@ -86,18 +108,32 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s if (hasHash) { - // A material an earlier push created, sent again because something about it changed. - if (!info.Authored || info.DiffuseImage == null) continue; + // A material an earlier push created. Sent without pixels it is unchanged — but the object + // wearing it may live in an archive that has never held its textures. + if (!info.Authored) continue; + if (info.DiffuseImage == null) + { + Carry(document, hash); + continue; + } lock (CreatedHere) CreatedHere.Add(hash); // stamped by an earlier session's ack MafiaMaterials.MaterialTextures current = MafiaMaterials.GetMaterialTextures(hash); + int mark = TextureChanges.Count; AuthoredMaterial? updated = WriteTextures(document, name, info, current.Diffuse, current.Normal, out skipReason); if (updated == null) return false; bool wasNormalMapped = current.Normal != null; if (wasNormalMapped != updated.NormalMapped) { - if (_host.Replace(hash, updated) is not { } replaced) + // Another shader is another record — deleted and made again. It has to be made again under + // the name the LIBRARY knows it by: the hash is derived from the name, and the name that + // arrived is whatever the Blender datablock is called today. Rebuilt under a new name the + // material gets a new hash, the old entry is gone, and every mesh outside this push still + // points at it. + string libraryName = MafiaMaterials.GetMaterialName(hash) ?? name; + if (_host.Replace(hash, updated with { Name = libraryName }) is not { } replaced) { + Rollback(mark); skipReason = $"could not rebuild game material '{name}' with its new maps"; return false; } @@ -107,15 +143,17 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s } else if (!_host.Update(hash, updated)) { + Rollback(mark); skipReason = $"material '{name}' is missing a texture slot it should have"; return false; } // Whatever kept its name was rewritten on disk under a renderer that already holds it. if (string.Equals(updated.Diffuse, current.Diffuse, StringComparison.OrdinalIgnoreCase)) - Rewritten.Add((hash, updated.Diffuse)); + NoteRewritten(hash, updated.Diffuse); if (updated.NormalSpecular != null && string.Equals(updated.NormalSpecular, current.Normal, StringComparison.OrdinalIgnoreCase)) - Rewritten.Add((hash, updated.NormalSpecular)); + NoteRewritten(hash, updated.NormalSpecular); + Carry(document, hash); Acknowledge(name, hash); continue; } @@ -126,6 +164,13 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s if (MafiaMaterials.FindHashByName(name) is { } known) { info.Hash = Format(known); + // The second object of a push to wear a new material arrives here: the first one created it. + // If the two live in different archives, this one's has none of the material's textures yet. + // Only for a material this bridge made — one the game shipped is bound by name and nothing + // of it is copied anywhere: its textures are wherever the game keeps them. + bool ours; + lock (CreatedHere) ours = CreatedHere.Contains(known); + if (ours) Carry(document, known); Acknowledge(name, known); continue; } @@ -136,10 +181,12 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s + "plug an Image Texture into it (plain colours are not supported yet)"; return false; } + int fresh = TextureChanges.Count; AuthoredMaterial? material = WriteTextures(document, name, info, null, null, out skipReason); if (material == null) return false; if (_host.Create(material) is not { } created) { + Rollback(fresh); skipReason = $"could not create game material '{name}'"; return false; } @@ -150,6 +197,57 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s return true; } + private readonly HashSet<(ulong, string)> _rewritten = new(); + + private void NoteRewritten(ulong hash, string texture) + { + if (_rewritten.Add((hash, texture.ToLowerInvariant()))) Rewritten.Add((hash, texture)); + } + + /// + /// Makes sure the archive lives in holds the textures of a Blender-made + /// material. A texture is packed into the archive of the object that wears it; a material shared by + /// objects of two archives therefore has to be in both, and the one that did not create it gets a copy + /// of the files the other holds — pixels or no pixels in this push. + /// + private void Carry(ISceneDocument document, ulong hash) + { + string dir = SdsMeshLoader.EnsureExtracted(document.SourceArchive); + MafiaMaterials.MaterialTextures textures = MafiaMaterials.GetMaterialTextures(hash); + foreach (string? texture in new[] { textures.Diffuse, textures.Normal, textures.Specular } + .Distinct(StringComparer.OrdinalIgnoreCase)) + { + if (string.IsNullOrEmpty(texture) || File.Exists(Path.Combine(dir, texture))) continue; + if (TextureSearchIndex.FindPath(texture) is not { } source || !File.Exists(source)) continue; + ArchiveTextureWriter.TextureState held = + ArchiveTextureWriter.Read(Path.GetDirectoryName(source)!, Path.GetFileName(source)); + if (held.Texture == null) continue; + + ArchiveTextureWriter.TextureState before = ArchiveTextureWriter.Read(dir, texture); + ArchiveTextureWriter.Write(dir, texture, held.Texture, held.TopLevel); + TextureChanges.Add(new TextureChange(dir, texture, before, held)); + TouchedArchives[document.SourceArchive.FullName] = document.SourceArchive; + } + } + + /// Puts back every texture written since — what a material that was + /// refused after its files were written leaves behind is nothing. + private void Rollback(int mark) + { + for (int i = TextureChanges.Count - 1; i >= mark; i--) + { + TextureChange change = TextureChanges[i]; + ArchiveTextureWriter.Restore(change.Dir, change.File, change.Before); + foreach ((string Dir, string Key) key in _written + .Where(w => w.Key.Dir == change.Dir && string.Equals(w.Value, change.File, StringComparison.OrdinalIgnoreCase)) + .Select(w => w.Key).ToList()) + { + _written.Remove(key); + } + } + TextureChanges.RemoveRange(mark, TextureChanges.Count - mark); + } + private void Acknowledge(string name, ulong hash) { if (!_acknowledged.Add(name)) return; @@ -158,6 +256,13 @@ private void Acknowledge(string name, ulong hash) Resolved.Add(new PushMaterial { Name = name, Hash = Format(hash), Authored = authored }); } + // "BLENDER_OBJECT" → "Blender object". + private static string Friendly(string space) + { + string words = space.Replace('_', ' ').ToLowerInvariant(); + return char.ToUpperInvariant(words[0]) + words[1..]; + } + private static string Format(ulong hash) => "0x" + hash.ToString("X16", System.Globalization.CultureInfo.InvariantCulture); @@ -169,26 +274,41 @@ private static string Format(ulong hash) => reason = null; string dir = SdsMeshLoader.EnsureExtracted(document.SourceArchive); + // Everything is read and checked BEFORE the first file is touched. The diffuse texture used to be on + // disk before the normal map had been looked at, so a material refused over a bad normal map had + // already had its picture replaced — reported as skipped, and changed. byte[]? diffusePixels = Pixels(info.DiffuseImage!, out reason); if (diffusePixels == null) return null; - string? diffuse = Write(document, dir, "d:" + info.DiffuseImage!.Block, info.DiffuseImage.Name, currentDiffuse, - diffusePixels, info.DiffuseImage.Width, info.DiffuseImage.Height, out reason); - if (diffuse == null) return null; - - string? normalSpecular = null; + byte[]? packed = null; + int packedWidth = 0, packedHeight = 0; if (info.NormalImage != null || info.SpecularImage != null) { byte[]? normal = null, specular = null; if (info.NormalImage != null && (normal = Pixels(info.NormalImage, out reason)) == null) return null; if (info.SpecularImage != null && (specular = Pixels(info.SpecularImage, out reason)) == null) return null; - byte[] packed = NormalSpecularPacker.Pack( + packed = NormalSpecularPacker.Pack( normal, info.NormalImage?.Width ?? 0, info.NormalImage?.Height ?? 0, specular, info.SpecularImage?.Width ?? 0, info.SpecularImage?.Height ?? 0, - out int width, out int height); + out packedWidth, out packedHeight); + } + + // …and whatever does get written before a later file fails is put back. + int mark = TextureChanges.Count; + string? diffuse = Write(document, dir, "d:" + info.DiffuseImage!.Block, info.DiffuseImage.Name, currentDiffuse, + diffusePixels, info.DiffuseImage.Width, info.DiffuseImage.Height, out reason); + if (diffuse == null) return null; + + string? normalSpecular = null; + if (packed != null) + { // One combined texture per material: it is made of two images and belongs to neither. normalSpecular = Write(document, dir, $"ns:{info.NormalImage?.Block}:{info.SpecularImage?.Block}", - name.Replace('.', '_') + "_ns", currentNormalSpecular, packed, width, height, out reason); - if (normalSpecular == null) return null; + name.Replace('.', '_') + "_ns", currentNormalSpecular, packed, packedWidth, packedHeight, out reason); + if (normalSpecular == null) + { + Rollback(mark); + return null; + } } // Blender's roughness and specular level, as the game's Phong power and level. A heuristic, tuned @@ -226,9 +346,15 @@ private static string Format(ulong hash) => try { (byte[] texture, byte[]? topLevel) = DdsEncoder.Encode(rgba, width, height); - string file = ArchiveTextureWriter.PickName(dir, imageName, reuse, texture); - ArchiveTextureWriter.Write(dir, file, texture, topLevel); + string file = ArchiveTextureWriter.PickName(dir, imageName, reuse, texture, topLevel); + ArchiveTextureWriter.TextureState before = ArchiveTextureWriter.Read(dir, file); _written[(dir, key)] = file; + // The same picture under the same name is nothing to write, undo or reload. + if (before.Is(texture, topLevel)) return file; + + ArchiveTextureWriter.Write(dir, file, texture, topLevel); + TextureChanges.Add(new TextureChange( + dir, file, before, new ArchiveTextureWriter.TextureState(texture, topLevel))); TouchedArchives[document.SourceArchive.FullName] = document.SourceArchive; return file; } diff --git a/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs b/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs index 20b7756..a51b9f6 100644 --- a/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs +++ b/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs @@ -20,22 +20,26 @@ public static class ArchiveTextureWriter /// answers to — neither in this archive nor anywhere else in the mirror, since a shared name would /// either overwrite a shipped texture or be shadowed by one. is a name the /// caller already owns (a re-push of the same image) and is handed back unchanged; so is a name in this - /// archive whose file is byte for byte — two materials sharing one image - /// share one texture. + /// archive that already holds exactly this picture — two materials sharing one image share one texture. + /// + /// "Exactly this picture" is BOTH files. A texture with a side of 256 or more is stored split: the entry + /// itself starts at half resolution, and the top level sits beside it as MIP_name.dds. Two different + /// pictures can agree on everything from half resolution down — a fine checkerboard and the flat grey it + /// averages to do — so comparing alone would hand the second one the first + /// one's name, and writing it would then replace the first material's top level. + /// /// - public static string PickName(string extractedDir, string imageName, string? reuse = null, byte[]? content = null) + /// The top level that goes with , or null when the + /// picture is stored as one file. + public static string PickName(string extractedDir, string imageName, string? reuse = null, byte[]? content = null, + byte[]? topLevel = null) { string stem = Sanitize(Path.GetFileNameWithoutExtension(imageName)); string candidate = stem + ".dds"; for (int n = 2; ; n++) { if (string.Equals(candidate, reuse, StringComparison.OrdinalIgnoreCase)) return candidate; - var existing = new FileInfo(Path.Combine(extractedDir, candidate)); - if (content != null && existing.Exists && existing.Length == content.Length - && File.ReadAllBytes(existing.FullName).AsSpan().SequenceEqual(content)) - { - return candidate; - } + if (content != null && Read(extractedDir, candidate).Is(content, topLevel)) return candidate; if (!File.Exists(Path.Combine(extractedDir, candidate)) && TextureSearchIndex.FindPath(candidate) == null) return candidate; candidate = $"{stem}_{n}.dds"; @@ -70,6 +74,52 @@ public static string Write(string extractedDir, string fileName, byte[] dds, byt return path; } + /// What a texture name holds in an archive's folder: the entry and the top level split off it, + /// each null when its file is not there. + public sealed record TextureState(byte[]? Texture, byte[]? TopLevel) + { + /// The archive has a texture of that name. + public bool Exists => Texture != null; + + /// Whether this is byte for byte the picture made of the two given files. + public bool Is(byte[] texture, byte[]? topLevel) => + Texture != null && Texture.AsSpan().SequenceEqual(texture) + && (topLevel == null ? TopLevel == null : TopLevel != null && TopLevel.AsSpan().SequenceEqual(topLevel)); + } + + /// Reads a texture and its top level as the folder has them now. + public static TextureState Read(string extractedDir, string fileName) + { + string path = Path.Combine(extractedDir, fileName); + string mipPath = Path.Combine(extractedDir, "MIP_" + fileName); + return new TextureState( + File.Exists(path) ? File.ReadAllBytes(path) : null, + File.Exists(mipPath) ? File.ReadAllBytes(mipPath) : null); + } + + /// Puts a texture name back to a state read earlier: rewritten when it existed then, taken out + /// of the folder and the manifest when it did not. + public static void Restore(string extractedDir, string fileName, TextureState state) + { + ArgumentNullException.ThrowIfNull(state); + if (state.Texture != null) Write(extractedDir, fileName, state.Texture, state.TopLevel); + else Remove(extractedDir, fileName); + } + + /// Takes a texture out of an archive's folder: both files and both manifest entries — an entry + /// naming a file that is gone fails the whole Build. + public static void Remove(string extractedDir, string fileName) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + ArgumentException.ThrowIfNullOrEmpty(fileName); + string mipName = "MIP_" + fileName; + SdsManifest manifest = SdsManifest.Load(extractedDir); + manifest.RemoveEntry(fileName); + manifest.RemoveEntry(mipName); + File.Delete(Path.Combine(extractedDir, fileName)); + File.Delete(Path.Combine(extractedDir, mipName)); + } + private static void Replace(string path, byte[] bytes) { string temp = path + ".tmp"; diff --git a/src/Illusion.Bridge/Payload/MeshPayloadCodec.cs b/src/Illusion.Bridge/Payload/MeshPayloadCodec.cs index bcf02da..6a4641f 100644 --- a/src/Illusion.Bridge/Payload/MeshPayloadCodec.cs +++ b/src/Illusion.Bridge/Payload/MeshPayloadCodec.cs @@ -37,6 +37,13 @@ public sealed class MeshMaterialInfo [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] public MaterialImageRef? NormalImage { get; set; } + /// Blender → toolkit only: the Space of the material's Normal Map node when it is NOT tangent + /// space ("OBJECT", "WORLD", …). Such a map is not sent as — the game reads + /// tangent-space normals only — and the material is refused with the reason. + [JsonPropertyName("normalSpace")] + [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] + public string? NormalSpace { get; set; } + /// Blender → toolkit only: the image wired into the specular input. [JsonPropertyName("specularImage")] [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] diff --git a/src/Illusion.Rendering/Gpu/GpuMesh.cs b/src/Illusion.Rendering/Gpu/GpuMesh.cs index 291128b..06c50bc 100644 --- a/src/Illusion.Rendering/Gpu/GpuMesh.cs +++ b/src/Illusion.Rendering/Gpu/GpuMesh.cs @@ -122,6 +122,11 @@ public void RefreshPose() // user is gone can actually be released (otherwise VRAM grows for the whole session while streaming). private TextureLibrary? _textures; private readonly List _textureLeases = new(); + + // …and per part, so a part that is rebound can return what it held. Without this every repaint of a + // texture left one more GPU copy alive for as long as the mesh stayed loaded: the library retires a + // rewritten texture until its last lease comes back, and a lease only ever came back on dispose. + private readonly List> _partLeases = new(); private bool _disposed; public static GpuMesh Create(GpuContext gpu, MeshData mesh, TextureLibrary textures) @@ -204,16 +209,18 @@ public static GpuMesh Create(GpuContext gpu, MeshData mesh, TextureLibrary textu result._textures = textures; foreach (MeshPart part in mesh.Parts) { + var leases = new List(3); result.Parts.Add(new GpuPart { StartIndex = (uint)part.StartIndex, IndexCount = (uint)part.IndexCount, MaterialHash = part.MaterialHash, Tint = part.Tint, - Srv = textures.Acquire(part.DiffuseTexture, result._textureLeases), - NormalSrv = textures.AcquireNormalOrFlat(part.NormalTexture, result._textureLeases), // flat-normal when absent - SpecSrv = textures.Acquire(part.SpecularTexture, result._textureLeases), // white when absent → spec level ×1 + Srv = textures.Acquire(part.DiffuseTexture, leases), + NormalSrv = textures.AcquireNormalOrFlat(part.NormalTexture, leases), // flat-normal when absent + SpecSrv = textures.Acquire(part.SpecularTexture, leases), // white when absent → spec level ×1 }); + result._partLeases.Add(leases); } if (instanced) @@ -297,9 +304,9 @@ private static Matrix4x4 Affine(Matrix4x4 m) /// /// Re-resolves the textures of every part bound to (a material edit changed - /// its slots). New SRVs come from the same library the mesh acquired from; the superseded leases stay recorded - /// until the mesh is disposed — a few stale leases per edit is bounded, and releasing them early would need - /// per-part lease bookkeeping. Returns the number of parts rebound. + /// its slots). New SRVs come from the same library the mesh acquired from, and the ones the part held are + /// handed back once it holds the new ones — other parts and other meshes keep their own leases on the same + /// texture. Returns the number of parts rebound. /// public int RebindPartTextures(ulong materialHash, string? diffuse, string? normal, string? specular, Vector4 tint) @@ -310,11 +317,13 @@ public int RebindPartTextures(ulong materialHash, string? diffuse, string? norma { if (Parts[i].MaterialHash != materialHash) continue; GpuPart p = Parts[i]; - p.Srv = _textures.Acquire(diffuse, _textureLeases); - p.NormalSrv = _textures.AcquireNormalOrFlat(normal, _textureLeases); - p.SpecSrv = _textures.Acquire(specular, _textureLeases); + var fresh = new List(3); + p.Srv = _textures.Acquire(diffuse, fresh); + p.NormalSrv = _textures.AcquireNormalOrFlat(normal, fresh); + p.SpecSrv = _textures.Acquire(specular, fresh); p.Tint = tint; // a material's colour is as editable as its textures — see MafiaMaterials Parts[i] = p; + Supersede(i, fresh); rebound++; } return rebound; @@ -328,14 +337,29 @@ public bool SetPartMaterial(int index, ulong materialHash, string? diffuse, stri if (_disposed || _textures == null || index < 0 || index >= Parts.Count) return false; GpuPart p = Parts[index]; p.MaterialHash = materialHash; - p.Srv = _textures.Acquire(diffuse, _textureLeases); - p.NormalSrv = _textures.AcquireNormalOrFlat(normal, _textureLeases); - p.SpecSrv = _textures.Acquire(specular, _textureLeases); + var fresh = new List(3); + p.Srv = _textures.Acquire(diffuse, fresh); + p.NormalSrv = _textures.AcquireNormalOrFlat(normal, fresh); + p.SpecSrv = _textures.Acquire(specular, fresh); p.Tint = tint; Parts[index] = p; + Supersede(index, fresh); return true; } + // The new leases are taken BEFORE the old ones go back: a part rebound to the texture it already has must + // not drop that texture's last reference on the way. + private void Supersede(int part, List fresh) + { + if (part >= _partLeases.Count) + { + _textureLeases.AddRange(fresh); // a part added outside Create: returned on dispose, as before + return; + } + _textures!.Release(_partLeases[part]); + _partLeases[part] = fresh; + } + /// Sets a new world matrix and refreshes the world-space AABB (used by frustum culling + picking). public void SetWorld(Matrix4x4 world) { @@ -420,6 +444,7 @@ public void Dispose() if (Instanced) InstanceBuffer.Dispose(); if (IsSkinned) SkinBuffer.Dispose(); // SRVs belong to TextureLibrary — returning the leases lets it evict entries with no users left. + foreach (List leases in _partLeases) _textures?.Release(leases); _textures?.Release(_textureLeases); } } diff --git a/src/Illusion.Rendering/Textures/TextureLibrary.cs b/src/Illusion.Rendering/Textures/TextureLibrary.cs index c9cc084..17f1e3c 100644 --- a/src/Illusion.Rendering/Textures/TextureLibrary.cs +++ b/src/Illusion.Rendering/Textures/TextureLibrary.cs @@ -100,6 +100,13 @@ public void Invalidate(string? name) } } + /// How many rewritten textures are still held by a mesh that has not rebound — zero once every + /// part drawing one has taken the new picture. What a repaint leaks, if it leaks. + public int RetiredCount + { + get { lock (_sync) return _retired.Count; } + } + /// Resolves a texture and records the acquisition in (nothing is /// recorded for an empty name or an uncached miss). Pass the leases back via when /// the owning mesh is disposed. diff --git a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py index 1aa84cd..c280958 100644 --- a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py +++ b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py @@ -311,6 +311,14 @@ def _describe_authored(material, entry, blocks, image_refs, signatures): level = materials.specular_level(material) roughness = materials.roughness(material) + # A Normal Map node in Object or World space is not something the game can use, and it is said + # rather than dropped: the toolkit refuses the material by name, where a silently missing normal + # map would only show as "the lighting is off". Sent on every push — whether the images changed + # or not — so the refusal stays until the node is put back to Tangent. + space = materials.normal_map_space(material) + if space is not None and space != 'TANGENT': + entry["normalSpace"] = space + # Unsaved paint on any image has no cheap identity, so it resends every time (signature None). parts = [materials.image_signature(i) for i in (diffuse, normal, specular) if i is not None] signature = None if any(p is None for p in parts) else "|".join( diff --git a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py index 4d2273a..9a811b2 100644 --- a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py +++ b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py @@ -153,16 +153,31 @@ def normal_map_image(material): """The image behind the Normal Map node feeding the Principled BSDF's Normal, or None. Only a tangent-space Normal Map node counts: a Bump node's height map is a different thing, and - sending it as a normal map would light the surface from nowhere. + sending it as a normal map would light the surface from nowhere. So is a Normal Map node set to + Object or World space — its channels are directions in the object or the world, and the packer + would read them as tangent-space X and Y (and flip the green). `normal_map_space` says which it is, + so the push can refuse the material in words instead of exporting wrong lighting. """ + node = _normal_map_node(material) + if node is None or node.space != 'TANGENT': + return None + return _image_behind(node.inputs.get("Color")) + + +def normal_map_space(material): + """The Space of the Normal Map node feeding the Principled BSDF's Normal ('TANGENT', 'OBJECT', + 'WORLD', ...), or None when there is no such node.""" + node = _normal_map_node(material) + return None if node is None else node.space + + +def _normal_map_node(material): principled = _principled(material) socket = None if principled is None else principled.inputs.get("Normal") if socket is None or not socket.is_linked: return None node = socket.links[0].from_node - if node.type != 'NORMAL_MAP': - return None - return _image_behind(node.inputs.get("Color")) + return node if node.type == 'NORMAL_MAP' else None def specular_image(material): @@ -262,8 +277,12 @@ def _resample(pixels, size, axis): position = (np.arange(size) + 0.5) * current / size - 0.5 low = np.floor(position) weight = (position - low).astype(np.float32) + # Each neighbour is clamped on its own. When a size is rounded UP, the first new texel's centre lies + # before source texel 0, so `low` is -1: the pair is meant to be (0, 0). Deriving the second index from + # the already-clamped first made it (0, 1) with nearly all the weight on texel 1 — the first row and + # column of every upsampled image came out as their neighbour. first = np.clip(low.astype(np.int64), 0, current - 1) - second = np.clip(first + 1, 0, current - 1) + second = np.clip(low.astype(np.int64) + 1, 0, current - 1) shape = [1, 1, 1] shape[axis] = size weight = weight.reshape(shape) diff --git a/src/Illusion/Bridge/BridgeSessionController.cs b/src/Illusion/Bridge/BridgeSessionController.cs index 5ef830e..336e8a3 100644 --- a/src/Illusion/Bridge/BridgeSessionController.cs +++ b/src/Illusion/Bridge/BridgeSessionController.cs @@ -912,7 +912,7 @@ private void ApplyPush(PushMessage push) // changed; and one rewritten under its old name changed no material, so the renderer has // to be told to read the file again. foreach (FileInfo archive in authored.TouchedArchives.Values) _host.Persistence.MarkArchiveModified(archive); - foreach ((ulong hash, string texture) in authored.Rewritten) _host.MaterialEditing.ReloadTexture(hash, texture); + _host.MaterialEditing.ReloadTextureFiles(authored.Rewritten.Select(r => r.Texture)); geometry.RemoveAll(g => !_host.Tree.IsInScene(g.Node)); transforms.RemoveAll(t => !_host.Tree.IsInScene(t.Node)); @@ -941,6 +941,11 @@ private void ApplyPush(PushMessage push) // Build the collision edits here, on the UI thread, so their scene lookups happen where the // scene is owned. The cooking they depend on already finished out on the bridge thread. var collisionEdits = new List(); + // The texture files this push wrote are part of the push: undone with it, back with a redo. + // The catalog's own history cannot do it — a repaint under the same name changes no binding + // and so leaves no entry there at all. + if (authored.TextureChanges.Count > 0) + collisionEdits.Add(new TextureFilesEdit(_host, authored.TextureChanges)); foreach (ReshapedHull hull in reshapes) { if (!_host.Tree.IsInScene(hull.Node) || hull.Node.Parent is not { } layer diff --git a/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs b/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs index 69a7f73..6da15bc 100644 --- a/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs +++ b/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs @@ -44,6 +44,9 @@ void Check(string name, bool ok, string detail = "") var pushedHashes = new List(); HashSet? folderBefore = null; string? extractedDir = null; + string? otherDir = null; + byte[]? otherManifest = null; + HashSet? otherBefore = null; ulong createdHash = 0; try { @@ -300,6 +303,267 @@ static string Shape(Formats.Materials.Versions.Material_v57 m) => upgraded && gained.Hash == slot.Hash && MafiaMaterials.GetMaterialTextures(createdHash).Normal != null && MafiaMaterials.Collection?.FindByHash(createdHash)?.ShaderID == 5159568776351604322, reason ?? gained.Hash); + // ════ Review of #4 ════ + + // ── A split texture is two files, and both are its content ── + // A fine checkerboard and the grey it averages to agree on everything from half resolution down, + // which is all the entry of a 256×256 texture holds; they differ in the top level beside it. + { + byte[] checker = new byte[256 * 256 * 4], grey = Solid(256, 256, 128, 128, 128); + for (int i = 0; i < 256 * 256; i++) + { + byte v = ((i % 256) + (i / 256)) % 2 == 0 ? (byte)0 : (byte)255; + checker[i * 4] = checker[(i * 4) + 1] = checker[(i * 4) + 2] = v; + checker[(i * 4) + 3] = 255; + } + (byte[] checkerEntry, byte[]? checkerTop) = DdsEncoder.Encode(checker, 256, 256); + (byte[] greyEntry, byte[]? greyTop) = DdsEncoder.Encode(grey, 256, 256); + bool sameEntry = checkerEntry.AsSpan().SequenceEqual(greyEntry); + bool otherTop = checkerTop != null && greyTop != null && !checkerTop.AsSpan().SequenceEqual(greyTop); + Check("two different 256×256 pictures can share every byte of the entry and differ only in the top level", + sameEntry && otherTop, $"entries equal {sameEntry}, top levels differ {otherTop}"); + + string stone = ArchiveTextureWriter.PickName(extracted, "illusion_probe_stone.png", null, checkerEntry, checkerTop); + ArchiveTextureWriter.Write(extracted, stone, checkerEntry, checkerTop); + string otherName = ArchiveTextureWriter.PickName(extracted, "illusion_probe_stone.jpg", null, greyEntry, greyTop); + Check("the second picture is NOT handed the first one's name", !otherName.Equals(stone, StringComparison.OrdinalIgnoreCase), + $"{stone} / {otherName}"); + Check("…while the same picture again still shares it", + ArchiveTextureWriter.PickName(extracted, "illusion_probe_stone.jpg", null, checkerEntry, checkerTop) == stone); + Check("…and a picture stored whole is not the split one that has the same entry", + !ArchiveTextureWriter.Read(extracted, stone).Is(checkerEntry, null)); + } + + // ── A material refused half-way leaves its pictures as they were ── + string held = MafiaMaterials.GetMaterialTextures(createdHash).Diffuse ?? "?"; + byte[] heldBytes = File.ReadAllBytes(Path.Combine(extracted, held)); + { + // Valid new pixels on Base Color, a normal map whose block never arrived. + var broken = new ExchangeContainer(); + MeshMaterialInfo half = NewSlot(broken, Gradient(w, h, 64), w, h); + half.Hash = slot.Hash; + half.NormalImage = new MaterialImageRef { Name = "illusion_probe_missing.png", Width = 64, Height = 64, Block = 9999 }; + var refusing = new AuthoredMaterialResolver(broken, catalogHost); + bool taken = refusing.TryResolve( + new MeshObjectPayload { Id = "new:probe", Name = "probe", Materials = { half } }, document, out reason); + Check("an update whose normal map did not arrive is refused", !taken && reason != null, reason ?? ""); + Check("…and the diffuse texture it would have replaced is untouched", + File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) + && refusing.TextureChanges.Count == 0, $"{refusing.TextureChanges.Count} file(s) left changed"); + + // Everything valid, and the LIBRARY says no after the files are on disk. + var fine = new ExchangeContainer(); + MeshMaterialInfo whole = NewSlot(fine, Gradient(w, h, 200), w, h); + whole.Hash = slot.Hash; + whole.NormalImage = Image(fine, "illusion_probe_normal.png", 64, 64, 128, 128, 255); + var unlucky = new AuthoredMaterialResolver(fine, new RefusingHost()); + bool landed = unlucky.TryResolve( + new MeshObjectPayload { Id = "new:probe", Name = "probe", Materials = { whole } }, document, out reason); + Check("an update the library refuses is refused", !landed, reason ?? ""); + Check("…and the files written for it are put back", + File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) + && unlucky.TextureChanges.Count == 0); + } + + // ── What a push wrote can be taken back, and put back ── + { + var repaint = new ExchangeContainer(); + MeshMaterialInfo painted = NewSlot(repaint, Gradient(w, h, 31), w, h); + painted.Hash = slot.Hash; + painted.NormalImage = Image(repaint, "illusion_probe_normal.png", 64, 64, 128, 128, 255); + var journal = new AuthoredMaterialResolver(repaint, catalogHost); + // A hundred objects wearing the one material: one file written, one reload asked for. + bool all = true; + for (int i = 0; i < 100 && all; i++) + { + MeshMaterialInfo copy = i == 0 ? painted : new MeshMaterialInfo + { + Hash = slot.Hash, Name = ProbeMaterial, Authored = true, + DiffuseImage = painted.DiffuseImage, NormalImage = painted.NormalImage, + }; + all = journal.TryResolve( + new MeshObjectPayload { Id = "new:probe" + i, Name = "probe", Materials = { copy } }, document, out reason); + } + byte[] repainted = File.ReadAllBytes(Path.Combine(extracted, held)); + AuthoredMaterialResolver.TextureChange? change = journal.TextureChanges.FirstOrDefault(c => c.File == held); + Check("a repaint under the same name is recorded with what the file held before", + all && change?.Before.Texture != null && change.Before.Texture.AsSpan().SequenceEqual(heldBytes) + && !repainted.AsSpan().SequenceEqual(heldBytes), reason ?? $"{journal.TextureChanges.Count} change(s)"); + Check("a hundred objects sharing the material ask for each texture to be reloaded once", + journal.Rewritten.Count == journal.Rewritten.Distinct().Count() + && journal.Rewritten.Count(r => r.Texture == held) == 1 + && journal.TextureChanges.Count(c => c.File == held) == 1, + $"{journal.Rewritten.Count} reload(s), {journal.TextureChanges.Count} file change(s)"); + if (change != null) + { + ArchiveTextureWriter.Restore(change.Dir, change.File, change.Before); + Check("undo puts the old picture back, still listed in the manifest", + File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) + && SdsManifest.Load(extracted).HasFile(held)); + ArchiveTextureWriter.Restore(change.Dir, change.File, change.After); + Check("redo puts the new one back", + File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(repainted)); + } + + // A texture the push INTRODUCED goes away with it — file and manifest entry both. + var novel = new ExchangeContainer(); + MeshMaterialInfo born = NewSlot(novel, Gradient(256, 256, 5), 256, 256); + born.Name = ProbeMaterial + "_born"; + born.DiffuseImage!.Name = "illusion_probe_born.png"; + var birth = new AuthoredMaterialResolver(novel, catalogHost); + bool made = birth.TryResolve( + new MeshObjectPayload { Id = "new:born", Name = "probe", Materials = { born } }, document, out reason); + if (made && BridgeMeshApplier.TryParseMaterialHash(born.Hash, out ulong bornHash)) pushedHashes.Add(bornHash); + AuthoredMaterialResolver.TextureChange? introduced = birth.TextureChanges.FirstOrDefault(); + Check("a texture a push introduces is recorded as not having been there", + made && introduced != null && !introduced.Before.Exists && introduced.After.TopLevel != null, reason ?? ""); + if (introduced != null) + { + ArchiveTextureWriter.Restore(introduced.Dir, introduced.File, introduced.Before); + SdsManifest gone = SdsManifest.Load(extracted); + Check("undo removes it: neither file nor manifest entry is left, and the folder still packs", + !File.Exists(Path.Combine(extracted, introduced.File)) && !File.Exists(Path.Combine(extracted, "MIP_" + introduced.File)) + && !gone.HasFile(introduced.File) && !gone.HasFile("MIP_" + introduced.File) && Packs(extracted, out _)); + ArchiveTextureWriter.Restore(introduced.Dir, introduced.File, introduced.After); + Check("redo brings it back with its top level", + ArchiveTextureWriter.Read(extracted, introduced.File).Is(introduced.After.Texture!, introduced.After.TopLevel)); + } + } + + // ── A material renamed in Blender keeps the hash every other mesh knows it by ── + { + bool wasMapped = MafiaMaterials.GetMaterialTextures(createdHash).Normal != null; + var renamed = new ExchangeContainer(); + MeshMaterialInfo other = NewSlot(renamed, Gradient(w, h, 77), w, h); + other.Hash = slot.Hash; + other.Name = ProbeMaterial + "_renamed_in_blender"; + if (!wasMapped) other.NormalImage = Image(renamed, "illusion_probe_normal.png", 64, 64, 128, 128, 255); + var rename = new AuthoredMaterialResolver(renamed, catalogHost); + bool swapped = rename.TryResolve( + new MeshObjectPayload { Id = "new:probe", Name = "probe", Materials = { other } }, document, out reason); + Check("a renamed material that changes shader is rebuilt under the hash it had", + swapped && other.Hash == slot.Hash && MafiaMaterials.KnowsMaterial(createdHash) + && (MafiaMaterials.GetMaterialTextures(createdHash).Normal != null) != wasMapped, + reason ?? $"{other.Hash} (was {slot.Hash})"); + Check("…and the library still knows it by its own name, not by the datablock's new one", + MafiaMaterials.GetMaterialName(createdHash) == ProbeMaterial + && MafiaMaterials.FindHashByName(other.Name) == null); + } + + // ── A normal map that is not in tangent space is refused in words ── + { + var spaced = new ExchangeContainer(); + MeshMaterialInfo objectSpace = NewSlot(spaced, gradient, w, h); + objectSpace.Name = ProbeMaterial + "_object_space"; + objectSpace.NormalSpace = "OBJECT"; + var spaceCheck = new AuthoredMaterialResolver(spaced, catalogHost); + bool accepted = spaceCheck.TryResolve( + new MeshObjectPayload { Id = "new:space", Name = "probe", Materials = { objectSpace } }, document, out reason); + Check("a material whose Normal Map node is in Object space is refused, naming the fix", + !accepted && reason != null && reason.Contains("Tangent", StringComparison.Ordinal) + && spaceCheck.TextureChanges.Count == 0 && MafiaMaterials.FindHashByName(objectSpace.Name) == null, reason ?? ""); + } + + // ── A material shared by objects of two archives has its textures in both ── + { + // The smallest other district that has a scene: all this needs of it is a folder and a manifest. + FileInfo? otherSds = null; + ISceneDocument? otherDocument = null; + foreach (FileInfo candidate in Directory.GetFiles(MafiaEnvironment.CityFolder, "*.sds").Select(f => new FileInfo(f)) + .Where(f => !f.Name.Equals(sds.Name, StringComparison.OrdinalIgnoreCase) + && !f.Name.EndsWith("_z.sds", StringComparison.OrdinalIgnoreCase) + && File.Exists(Path.Combine(MafiaEnvironment.ExtractedDir(f), "SDSContent.xml"))) + .OrderBy(f => f.Length).Take(6)) + { + otherDocument = SdsMeshLoader.LoadHierarchy(candidate).Document; + if (otherDocument == null) continue; + otherSds = candidate; + break; + } + Check("a second archive was found to share a material with", otherDocument != null, otherSds?.Name ?? "none"); + if (otherSds != null && otherDocument != null) + { + otherDir = MafiaEnvironment.ExtractedDir(otherSds); + otherManifest = File.ReadAllBytes(Path.Combine(otherDir, "SDSContent.xml")); + otherBefore = new HashSet(Directory.GetFiles(otherDir), StringComparer.OrdinalIgnoreCase); + + var both = new ExchangeContainer(); + MeshMaterialInfo here = NewSlot(both, Gradient(256, 256, 9), 256, 256); + here.Name = ProbeMaterial + "_shared"; + here.DiffuseImage!.Name = "illusion_probe_shared.png"; + var there = new MeshMaterialInfo { Hash = "", Name = here.Name, Authored = true, DiffuseImage = here.DiffuseImage }; + var shared = new AuthoredMaterialResolver(both, catalogHost); + bool firstOk = shared.TryResolve( + new MeshObjectPayload { Id = "new:a", Name = "probe", Materials = { here } }, document, out reason); + bool secondOk = firstOk && shared.TryResolve( + new MeshObjectPayload { Id = "new:b", Name = "probe", Materials = { there } }, otherDocument, out reason); + BridgeMeshApplier.TryParseMaterialHash(here.Hash, out ulong sharedHash); + if (sharedHash != 0) pushedHashes.Add(sharedHash); + string sharedTexture = MafiaMaterials.GetMaterialTextures(sharedHash).Diffuse ?? "?"; + Check("both objects resolve to the one material", firstOk && secondOk && here.Hash == there.Hash, reason ?? ""); + Check("its texture and top level are in BOTH archives, each listed in its manifest", + ArchiveTextureWriter.Read(extracted, sharedTexture) is { Texture: not null, TopLevel: not null } a + && ArchiveTextureWriter.Read(otherDir, sharedTexture).Is(a.Texture, a.TopLevel) + && SdsManifest.Load(otherDir).HasFile(sharedTexture) && SdsManifest.Load(otherDir).HasFile("MIP_" + sharedTexture), + sharedTexture); + Check("both archives are queued for a rebuild", + shared.TouchedArchives.ContainsKey(sds.FullName) && shared.TouchedArchives.ContainsKey(otherSds.FullName)); + Check("the second archive still packs", Packs(otherDir, out string? otherPack), otherPack ?? ""); + + // …and when the material arrives with no pixels at all (nothing about it changed), an + // archive that has never held its textures still gets them. + ArchiveTextureWriter.Remove(otherDir, sharedTexture); + var later = new AuthoredMaterialResolver(new ExchangeContainer(), catalogHost); + var worn = new MeshMaterialInfo { Hash = here.Hash, Name = here.Name, Authored = true }; + bool carried = later.TryResolve( + new MeshObjectPayload { Id = "new:c", Name = "probe", Materials = { worn } }, otherDocument, out reason); + Check("an unchanged material worn in a new archive brings its textures without being re-sent", + carried && File.Exists(Path.Combine(otherDir, sharedTexture)) && later.TextureChanges.Count == 1 + && later.TouchedArchives.ContainsKey(otherSds.FullName), reason ?? ""); + } + } + + // ── A part that is rebound gives back the texture it held ── + // The library keeps a rewritten texture alive until the last mesh drawing it lets go; a part used + // to let go only when its mesh was disposed, so every repaint left one more copy on the GPU. + { + string gpuFolder = Path.Combine(Path.GetTempPath(), "illusion_bridge_material_gpu"); + Directory.CreateDirectory(gpuFolder); + try + { + using var gpu = new Rendering.Gpu.GpuContext(); + using var textures = new Rendering.Textures.TextureLibrary(gpu); + textures.AddFolder(gpuFolder); + const string painted = "illusion_probe_repaint.dds"; + File.WriteAllBytes(Path.Combine(gpuFolder, painted), DdsEncoder.EncodeDxt1(Gradient(w, h, 0), w, h)); + var triangle = new MeshData + { + Name = "probe", + World = System.Numerics.Matrix4x4.Identity, + Positions = [new(0, 0, 0), new(1, 0, 0), new(0, 1, 0)], + Normals = [System.Numerics.Vector3.UnitZ, System.Numerics.Vector3.UnitZ, System.Numerics.Vector3.UnitZ], + UVs = [new(0, 0), new(1, 0), new(0, 1)], + Indices = [0, 1, 2], + Parts = [new MeshPart(0, 3, painted, null, null, 0xABCDEF)], + }; + using Rendering.Gpu.GpuMesh mesh = Rendering.Gpu.GpuMesh.Create(gpu, triangle, textures); + int worst = 0; + for (int i = 1; i <= 20; i++) + { + File.WriteAllBytes(Path.Combine(gpuFolder, painted), DdsEncoder.EncodeDxt1(Gradient(w, h, i), w, h)); + textures.Invalidate(painted); + mesh.RebindPartTextures(0xABCDEF, painted, null, null, System.Numerics.Vector4.One); + worst = Math.Max(worst, textures.RetiredCount); + } + Check("twenty repaints of a texture a loaded mesh draws leave no old copy alive", + textures.RetiredCount == 0 && worst == 0, $"{textures.RetiredCount} retired at the end, {worst} at most"); + } + finally + { + Directory.Delete(gpuFolder, recursive: true); + } + } + // ── A container written by the addon itself ── if (pushedContainer != null) { @@ -358,6 +622,12 @@ static string Shape(Formats.Materials.Versions.Material_v57 m) => if (!folderBefore.Contains(file)) File.Delete(file); } if (manifestPath != null && manifestBytes != null) File.WriteAllBytes(manifestPath, manifestBytes); + if (otherDir != null && otherBefore != null && otherManifest != null) + { + foreach (string file in Directory.GetFiles(otherDir)) + if (!otherBefore.Contains(file)) File.Delete(file); + File.WriteAllBytes(Path.Combine(otherDir, "SDSContent.xml"), otherManifest); + } if (createdHash != 0) MafiaMaterialCatalog.Instance.RemoveMaterial(createdHash); // never saved to disk foreach (ulong hash in pushedHashes) MafiaMaterialCatalog.Instance.RemoveMaterial(hash); if (manifestBytes != null) sb.AppendLine("restored the manifest and removed the probe texture"); @@ -366,6 +636,16 @@ static string Shape(Formats.Materials.Versions.Material_v57 m) => } } + /// A library that takes nothing — what a push meets when the catalog cannot be written. + private sealed class RefusingHost : IAuthoredMaterialHost + { + public ulong? Create(AuthoredMaterial material) => null; + + public bool Update(ulong hash, AuthoredMaterial material) => false; + + public ulong? Replace(ulong hash, AuthoredMaterial material) => null; + } + private static MeshMaterialInfo NewSlot(ExchangeContainer container, byte[] rgba, int w, int h) => new() { Hash = "", diff --git a/src/Illusion/Viewport/MaterialEditController.cs b/src/Illusion/Viewport/MaterialEditController.cs index e535aad..4fe3246 100644 --- a/src/Illusion/Viewport/MaterialEditController.cs +++ b/src/Illusion/Viewport/MaterialEditController.cs @@ -163,10 +163,37 @@ public bool UpdateAuthored(ulong hash, AuthoredMaterial material) /// A texture FILE was rewritten under the name the material already uses: nothing in the catalog /// changed, so the renderer is told to forget its copy and every part drawing the material re-reads it. /// - public void ReloadTexture(ulong hash, string textureName) + public void ReloadTexture(ulong hash, string textureName) => ReloadTextureFiles([textureName]); + + /// + /// Several texture files were rewritten under their names — by a push, or by undoing one. Each is + /// forgotten once, and each material that names any of them rebinds its parts once: a push of a hundred + /// objects sharing a material used to invalidate and rebind a hundred times over, every time across + /// every loaded mesh. + /// + public void ReloadTextureFiles(IEnumerable textureNames) { - _host.Rnd?.Textures.Invalidate(textureName); - RefreshMeshesUsing(hash); + var names = new HashSet(textureNames, StringComparer.OrdinalIgnoreCase); + if (names.Count == 0 || _host.Rnd is not { } renderer) return; + foreach (string name in names) renderer.Textures.Invalidate(name); + + var drawn = new HashSet(); + foreach (GpuMesh gm in renderer.Meshes) + { + foreach (GpuPart part in gm.Parts) + { + if (part.MaterialHash != 0) drawn.Add(part.MaterialHash); + } + } + foreach (ulong hash in drawn) + { + MafiaMaterials.MaterialTextures tex = MafiaMaterials.GetMaterialTextures(hash); + if ((tex.Diffuse != null && names.Contains(tex.Diffuse)) || (tex.Normal != null && names.Contains(tex.Normal)) + || (tex.Specular != null && names.Contains(tex.Specular))) + { + RefreshMeshesUsing(hash); + } + } _host.RaiseMaterialsChanged(); } diff --git a/src/Illusion/Viewport/MaterialPreviewViewport.cs b/src/Illusion/Viewport/MaterialPreviewViewport.cs index a75d2ac..f4da36f 100644 --- a/src/Illusion/Viewport/MaterialPreviewViewport.cs +++ b/src/Illusion/Viewport/MaterialPreviewViewport.cs @@ -191,8 +191,10 @@ private static MeshData BuildFlatMesh(Vector3[] allPositions, uint[] indices, Me Indices = outIdx, Parts = new[] { + // The tint with the rest: a material that paints itself has no albedo, and without its + // colour the mesh preview showed it white while the sphere showed it painted. new MeshPart(0, outIdx.Length, - part.DiffuseTexture, part.NormalTexture, part.SpecularTexture, part.MaterialHash), + part.DiffuseTexture, part.NormalTexture, part.SpecularTexture, part.MaterialHash, part.Tint), }, }; } diff --git a/src/Illusion/Viewport/TextureFilesEdit.cs b/src/Illusion/Viewport/TextureFilesEdit.cs new file mode 100644 index 0000000..b23b6ab --- /dev/null +++ b/src/Illusion/Viewport/TextureFilesEdit.cs @@ -0,0 +1,55 @@ +using Illusion.Assets.Bridge; +using Illusion.Assets.Textures; +using Illusion.Domain; + +namespace Illusion.Viewport; + +/// +/// The texture files one Blender push wrote into archive folders, as part of that push's undo entry. +/// +/// A material remembers which texture it NAMES, and the bridge repaints an authored texture in place — so +/// after a repaint the catalog has nothing to undo, and Ctrl+Z took back the mesh while the old picture was +/// gone for good (or took back some earlier, unrelated edit when the push had changed nothing else). The +/// bytes each name held before and after are kept here instead, with the manifest entries that go with +/// them: undo puts the old files back — or removes a texture the push introduced — and redo the new ones. +/// +/// +internal sealed class TextureFilesEdit : IEditAction +{ + private readonly D3DImageHost _host; + private readonly IReadOnlyList _changes; + private bool _onDisk = true; // the resolver has already written them when the push is applied + + public TextureFilesEdit(D3DImageHost host, IReadOnlyList changes) + { + _host = host; + _changes = [.. changes]; + } + + public void Undo() + { + if (!_onDisk) return; + for (int i = _changes.Count - 1; i >= 0; i--) + { + ArchiveTextureWriter.Restore(_changes[i].Dir, _changes[i].File, _changes[i].Before); + } + _onDisk = false; + Reload(); + } + + public void Redo() + { + if (_onDisk) return; + foreach (AuthoredMaterialResolver.TextureChange change in _changes) + { + ArchiveTextureWriter.Restore(change.Dir, change.File, change.After); + } + _onDisk = true; + Reload(); + } + + // The folders changed under a renderer that holds the old pictures, and under archives whose .sds is + // now behind (or, after an undo, no longer ahead in that respect — but still not what was last packed). + private void Reload() => + _host.MaterialEditing.ReloadTextureFiles(_changes.Select(c => c.File)); +} From a3b5f7375e0402912e4380125fe89d1356ead17d Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:19:30 +0300 Subject: [PATCH 045/102] fix: the material preview blends what the scene blends; an alpha mask is sampled linear Review of #5, the two findings outside the mirror. The material editor's preview still alpha-tested a translucent material. SphereMesh forwards a part's Blended flag, but the part the preview builds for a material carried only its tint, and the mesh-shape preview dropped the flag again - so a uniform 0.3-alpha pane vanished from the ball while the scene drew it blended. The flag is read from the catalog with the tint and kept in both preview shapes. Add-on: a colour image used as an alpha mask was read through image_rgba8, which turns a picture into what an albedo texture stores (sRGB bytes), and the luminance of those encoded values was written as coverage. A float mask at linear 0.25 came out as 137/255 instead of 64 - enough to carry a cut-out across the 0.5 it is tested against. The mask is now read in the values the shader works with (_shader_pixels): float buffers as they are, 8-bit sRGB linearized as the Image Texture node does, Non-Color data and the alpha channel as stored. Run in a background Blender on the add-on as it stands in this branch: linear 0.25 float mask -> 64, 8-bit sRGB 0.5 -> 55, Non-Color 0.5 -> 128, alpha channel 0.3 -> 77; the same run covers the two add-on fixes merged from #4 (Normal Map node space, resampler edge). The preview change was not looked at in the material editor window. --- .../addons/illusion_bridge/materials.py | 32 ++++++++++++++++--- .../Viewport/MaterialPreviewViewport.cs | 9 ++++-- 2 files changed, 33 insertions(+), 8 deletions(-) diff --git a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py index 769b300..09d4e07 100644 --- a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py +++ b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/materials.py @@ -247,21 +247,43 @@ def with_alpha(pixels, width, height, diffuse, alpha): return out if image == diffuse and channel == "A": return pixels - packed = image_rgba8(image) - if packed is None: + # The mask is read the way the SHADER reads it — linear — and not through image_rgba8, which turns a + # picture into what an albedo texture stores (sRGB bytes). Coverage is a plain number: a float mask at + # linear 0.25 means a quarter, and taken through the albedo path it came out as 137/255 instead of 64 — + # enough to carry a cut-out across the 0.5 it is tested against. + mask = _shader_pixels(image) + if mask is None: return pixels - mask, mask_width, mask_height = packed - mask = mask.reshape(mask_height, mask_width, 4).astype(np.float32) mask = _resample(_resample(mask, width, axis=1), height, axis=0) if channel == "A": coverage = mask[..., 3] else: coverage = mask[..., 0] * 0.2126 + mask[..., 1] * 0.7152 + mask[..., 2] * 0.0722 out = pixels.copy() - out[:, 3] = np.clip(coverage + 0.5, 0.0, 255.0).astype(np.uint8).reshape(-1) + out[:, 3] = np.clip(coverage * 255.0 + 0.5, 0.0, 255.0).astype(np.uint8).reshape(-1) return out +def _shader_pixels(image): + """An image as float RGBA in the values a material's shader works with, rows top-down; None when it + has no pixels. + + A float buffer is scene-linear already. A byte image tagged sRGB holds encoded values, which the + Image Texture node linearizes on the way out of its Color socket — so they are linearized here. Data + tagged Non-Color, and the alpha channel of anything, is used as stored. + """ + width, height = image.size + if width == 0 or height == 0: + return None + pixels = np.empty(width * height * 4, dtype=np.float32) + image.pixels.foreach_get(pixels) + pixels = pixels.reshape(height, width, 4)[::-1].copy() + if not image.is_float and image.colorspace_settings.name == 'sRGB': + rgb = np.clip(pixels[..., :3], 0.0, 1.0) + pixels[..., :3] = np.where(rgb <= 0.04045, rgb / 12.92, np.power((rgb + 0.055) / 1.055, 2.4)) + return pixels + + def _principled(material): tree = getattr(material, "node_tree", None) if tree is None: diff --git a/src/Illusion/Viewport/MaterialPreviewViewport.cs b/src/Illusion/Viewport/MaterialPreviewViewport.cs index f4da36f..0dc0e2d 100644 --- a/src/Illusion/Viewport/MaterialPreviewViewport.cs +++ b/src/Illusion/Viewport/MaterialPreviewViewport.cs @@ -82,8 +82,10 @@ public void SetMaterial(ulong hash, string? diffuse, string? normal, string? spe { // The tint is part of the material's look, not a decoration: a car body has no albedo at all and // shows nothing but its colour, so a preview without it is a white ball whatever the paint says. - _part = new MeshPart(0, 0, diffuse, normal, specular, hash, - Assets.MafiaMaterials.GetMaterialTextures(hash).Tint); + // …and so is how its alpha is read. A translucent material previewed as a cut-out is tested against + // one half and a pane of glass at 0.3 simply vanishes from the ball, while the scene draws it blended. + Assets.MafiaMaterials.MaterialTextures look = Assets.MafiaMaterials.GetMaterialTextures(hash); + _part = new MeshPart(0, 0, diffuse, normal, specular, hash, look.Tint, look.Blended); _materialLighting = lighting; Lighting = lighting; Rebuild(); @@ -194,7 +196,8 @@ private static MeshData BuildFlatMesh(Vector3[] allPositions, uint[] indices, Me // The tint with the rest: a material that paints itself has no albedo, and without its // colour the mesh preview showed it white while the sphere showed it painted. new MeshPart(0, outIdx.Length, - part.DiffuseTexture, part.NormalTexture, part.SpecularTexture, part.MaterialHash, part.Tint), + part.DiffuseTexture, part.NormalTexture, part.SpecularTexture, part.MaterialHash, part.Tint, + part.Blended), }, }; } From b955fa624e53f3ab8bd04b733a6c5b25b9edd9d0 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 21:24:36 +0300 Subject: [PATCH 046/102] test: the scripted editor answers the resource status the tools now ask for --probe-editor-tools drives the tool methods against a stand-in editor. Since the resource editor became a target, a tool reads ResourceStatus to learn which editor it is reporting on; the stand-in had no answer and the save cases stopped there. It now hands back an empty status, which names no target, so the map's status is used. 23/23. --- src/Illusion/Diagnostics/Probes/EditorToolProbes.cs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/Illusion/Diagnostics/Probes/EditorToolProbes.cs b/src/Illusion/Diagnostics/Probes/EditorToolProbes.cs index 250e851..8b6f7d0 100644 --- a/src/Illusion/Diagnostics/Probes/EditorToolProbes.cs +++ b/src/Illusion/Diagnostics/Probes/EditorToolProbes.cs @@ -197,6 +197,10 @@ internal static (IEditorSession Session, Script Script) Create(EditorStatus stat return _script.Status; case nameof(IEditorSession.EnsureEditor): return null; + case nameof(IEditorSession.ResourceStatus): + // No resource editor in these cases: an empty status names no target, so a tool that + // asks which editor it is driving falls through to the map's. + return System.Runtime.CompilerServices.RuntimeHelpers.GetUninitializedObject(targetMethod.ReturnType); case nameof(IEditorSession.Areas): return (IReadOnlyList)["uppertown", "greenfield"]; case nameof(IEditorSession.LoadArea): From c4b33d4ed78ef19b196aa86c1ae97345865c0579 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:10:59 +0300 Subject: [PATCH 047/102] fix(cars): a clone or a substitution that fails takes itself back; resource_open stages what was asked for Found reviewing this branch the way #3-#5 were reviewed. car_clone deleted a working copy before it had checked anything. A folder under the new name with no archive beside it was removed as "left behind by an attempt that never produced its archive" - ahead of the checks on the title and the vehicle table, so a clone refused a moment later had already cost whatever the folder held (a parked car, work never built). Such a folder is now a refusal that names it, and nothing is deleted. Clone and substitute wrote several game files with no way back. A clone adds rows to shared working copies and then packs five or more archives; any pack failing (the game holding a file, a full disk) left a vehicle row with no archive, tables written and not packed, and a name that was now "taken", so the clone could not be tried again. A substitution that failed on the winter archive left summer substituted and winter stock, with the target's working copy already deleted. Both now keep a GameWriteJournal: rewritten files get their bytes back, new files and folders are removed, a replaced archive is restored from the backup taken of it a moment earlier (and that backup dropped), and a working copy that has to make way is moved aside until the operation has succeeded. The refusal says the operation was taken back and names anything that could not be. resource_open answered success with the previous archive on the stage. The resource editor's catalog is a walk of the game folder made when the window opens; an archive made since - a car cloned a moment ago, which is the workflow car_clone recommends - was never found, the request waited for good, and the tool's "something is loaded" test was met by the car already there. car_tuning_set then wrote into that car. The window now walks again for an archive it does not know (once per archive), and the tool waits for the archive it asked for and fails, naming what the stage shows, when it does not arrive. An archive with nothing to draw no longer runs out the whole timeout. --probe-car-clone 73/73 (8 new: every kind of journal entry undone on scratch files). --probe-car-clone-rollback (new; unlike the others it WRITES to the install and restores it): the real Clone with the winter pack blocked - refused onto an existing working copy without touching it; after the failure the summer archive, both folders, vehicles.tbl, the paint and traffic tables, cover points, manifest and text are as they were, tables.sds and ingame.sds unpacked with no backup left, and the same clone can be tried again. 11/11. resource_open checked live: a car archive copied into pc\sds\cars after the window opened is staged, by path and by bare name. The substitution's rollback is covered by the journal checks only, not by a staged failure against the install. --- .../Cars/CarCloner.Substituting.cs | 32 ++- src/Illusion.Assets/Cars/CarCloner.cs | 69 ++++-- src/Illusion.Assets/Cars/GameWriteJournal.cs | 159 ++++++++++++++ src/Illusion.Mcp/IEditorSession.cs | 2 +- src/Illusion.Mcp/Tools/EditorTools.cs | 24 ++- src/Illusion/Diagnostics/ProbeRunner.cs | 6 + .../Diagnostics/Probes/CarCloneProbes.cs | 196 ++++++++++++++++++ src/Illusion/Mcp/AppEditorSession.cs | 4 +- .../Views/ResourceEditorWindow.xaml.cs | 18 ++ 9 files changed, 486 insertions(+), 24 deletions(-) create mode 100644 src/Illusion.Assets/Cars/GameWriteJournal.cs diff --git a/src/Illusion.Assets/Cars/CarCloner.Substituting.cs b/src/Illusion.Assets/Cars/CarCloner.Substituting.cs index d0e283d..540b8a0 100644 --- a/src/Illusion.Assets/Cars/CarCloner.Substituting.cs +++ b/src/Illusion.Assets/Cars/CarCloner.Substituting.cs @@ -76,18 +76,34 @@ public static partial class CarCloner cars.Add((from, to)); } + // Both seasons or neither. The winter archive failing to pack used to leave the summer one substituted + // and the winter one stock — and the target's working copy already deleted. The copy is moved aside + // instead and only dropped once every archive is in; on a failure the replaced archives come back from + // the backups taken a moment earlier. DateTime when = DateTime.Now; var packed = new List<(string Archive, string? Backup)>(); - foreach ((FileInfo from, FileInfo to) in cars) + var journal = new GameWriteJournal(); + try { - string fromFolder = SdsMeshLoader.EnsureExtracted(from); - SdsWriter.EnsureMemoryRequirements(from); - string toFolder = MafiaEnvironment.ExtractedDir(to); - SdsWriter.DeleteExtracted(toFolder); - notes.AddRange(SubstituteExtracted(fromFolder, toFolder, sourceModel, targetModel)); - SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: true, when); - packed.Add((made.Archive, made.Backup)); + foreach ((FileInfo from, FileInfo to) in cars) + { + string fromFolder = SdsMeshLoader.EnsureExtracted(from); + SdsWriter.EnsureMemoryRequirements(from); + string toFolder = MafiaEnvironment.ExtractedDir(to); + journal.MoveAside(toFolder); + journal.WillCreateFolder(toFolder); + notes.AddRange(SubstituteExtracted(fromFolder, toFolder, sourceModel, targetModel)); + SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: true, when); + journal.Replaced(made.Archive, made.Backup); + packed.Add((made.Archive, made.Backup)); + } + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + refusal = TakenBack("the substitution", ex, journal.Undo()); + return null; } + journal.Commit(); notes.Add(GameFileIndex.Reset() ? "the game's file list (vfs.bin) was reset — the next start rebuilds it" : $"the game's file list was not reset — remove {GameFileIndex.Path} before starting the game"); diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs index 7cb985e..720f23c 100644 --- a/src/Illusion.Assets/Cars/CarCloner.cs +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -126,24 +126,61 @@ [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.Ex // the clone asks too — written beside the source's working copy here, it is copied with the folder. foreach ((FileInfo from, FileInfo _) in cars) SdsWriter.EnsureMemoryRequirements(from); - // A folder left behind by an attempt that never produced its archive is nobody's working copy. - foreach ((string _, string to) in folders.Cars) SdsWriter.DeleteExtracted(to); + // A working copy under the new name, with no archive beside it. It used to be deleted here as "a folder + // left behind by an attempt that never produced its archive" — before anything had been checked, so a + // clone refused a moment later for its title had already cost whatever that folder held. A failed + // clone now removes what it made (below), so a folder found here is somebody's: a parked car, work + // not yet built. It is left alone and said. + foreach ((string _, string to) in folders.Cars) + { + if (!Directory.Exists(to)) continue; + refusal = $"there is already a working copy at {to}, though {Path.GetFileName(to)} is not in pc\\sds\\cars — " + + "it may hold work that was never built. Remove or rename that folder, or pick another name"; + return null; + } - CarCloneResult? result = CloneExtracted(folders, stem, name, traffic, title, out refusal); - if (result == null) return null; + // From here on the clone writes: new folders, rows in shared tables, then five or more archives packed + // one after another. Whatever fails, all of it is taken back — a row with no archive behind it, or + // tables written and not packed, is a state no step of this intended, and one that used to make the + // retry impossible ("already exists"). + var journal = new GameWriteJournal(); + journal.Remember(Path.Combine(folders.Tables, "tables", "vehicles.tbl")); + foreach (string text in folders.Text) journal.Remember(Path.Combine(text, "tables", "TextDatabase.dat")); + string ingameTables = Path.Combine(folders.Ingame, "tables"); + if (Directory.Exists(ingameTables)) + { + foreach (string table in Directory.GetFiles(ingameTables, "*.tbl")) journal.Remember(table); + } + journal.Remember(Path.Combine(folders.Ingame, "SDSContent.xml")); + journal.RememberContents(Path.Combine(ingameTables, "AiProps")); + foreach ((string _, string to) in folders.Cars) journal.WillCreateFolder(to); - DateTime when = DateTime.Now; + CarCloneResult? result; var packed = new List<(string Archive, string? Backup)>(); - foreach ((FileInfo _, FileInfo to) in cars) + try { - SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: false, when); - packed.Add((made.Archive, made.Backup)); + result = CloneExtracted(folders, stem, name, traffic, title, out refusal); + if (result == null) return null; // refused before writing anything + + DateTime when = DateTime.Now; + foreach ((FileInfo _, FileInfo to) in cars) + { + journal.WillCreateFile(to.FullName); + SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: false, when); + packed.Add((made.Archive, made.Backup)); + } + IEnumerable changed = result.TextId == null ? [tablesSds, ingameSds] : [tablesSds, ingameSds, .. textSds]; + foreach (FileInfo table in changed) + { + SdsWriter.PackResult made = SdsWriter.PackSds(table, createBackup: true, when); + journal.Replaced(made.Archive, made.Backup); + packed.Add((made.Archive, made.Backup)); + } } - IEnumerable changed = result.TextId == null ? [tablesSds, ingameSds] : [tablesSds, ingameSds, .. textSds]; - foreach (FileInfo table in changed) + catch (Exception ex) when (ex is not OutOfMemoryException) { - SdsWriter.PackResult made = SdsWriter.PackSds(table, createBackup: true, when); - packed.Add((made.Archive, made.Backup)); + refusal = TakenBack("the clone", ex, journal.Undo()); + return null; } // The game finds archives through its cached file list, and a new one is not in it. var notes = new List(result.Notes); @@ -153,6 +190,14 @@ [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.Ex return new CarCloneOutcome(name, result.VehicleId, packed, result.TrafficRows, result.TextId, notes); } + // What a failed multi-file operation tells its caller: that it failed, that it was undone, and — when the + // undo itself met a locked file — exactly what is still out of place. + private static string TakenBack(string what, Exception ex, List problems) => + $"{what} failed and was taken back: {ex.Message}" + + (problems.Count == 0 + ? " — the game's files and the working copies are as they were" + : " — but NOT everything could be put back: " + string.Join("; ", problems)); + /// /// The clone itself, on working copies only: copies each car folder to its new place and renames what the /// name keys there, then adds the vehicle, its paint combinations, its cover points and (optionally) its diff --git a/src/Illusion.Assets/Cars/GameWriteJournal.cs b/src/Illusion.Assets/Cars/GameWriteJournal.cs new file mode 100644 index 0000000..2d440f0 --- /dev/null +++ b/src/Illusion.Assets/Cars/GameWriteJournal.cs @@ -0,0 +1,159 @@ +using Illusion.Assets.Sds; + +namespace Illusion.Assets.Cars; + +/// +/// What an operation that writes SEVERAL game files has done so far, so that it can be taken back when a +/// later step fails. +/// +/// Cloning a car writes a new archive, rows in three shared tables and a string per language, then packs +/// five or more archives one after another. Any of those packs can fail — the game is running and holds the +/// file, the disk is full, an antivirus has it open — and stopping there leaves a state no single step +/// intended: a vehicle row with no archive behind it, tables written but not packed, a name that is now +/// "taken" so the clone cannot even be tried again. Nothing in the game's files is transactional, so the +/// operation keeps this list and undoes itself: files put back as they were, new ones removed, replaced +/// archives restored from the backup taken of them a moment earlier. +/// +/// +internal sealed class GameWriteJournal +{ + private readonly List<(string Path, byte[]? Before)> _files = []; + private readonly List<(string Folder, HashSet Before)> _folders = []; + private readonly List _createdFolders = []; + private readonly List _createdFiles = []; + private readonly List<(string Folder, string Aside)> _movedAside = []; + private readonly List<(string Archive, string? Backup)> _replaced = []; + + /// Remembers a file as it is now — its bytes, or that it is not there. + public void Remember(string path) + { + if (_files.Any(f => string.Equals(f.Path, path, StringComparison.OrdinalIgnoreCase))) return; + _files.Add((path, File.Exists(path) ? File.ReadAllBytes(path) : null)); + } + + /// Remembers which files a folder holds, so that ones added later can be removed. + public void RememberContents(string folder) + { + var before = new HashSet(StringComparer.OrdinalIgnoreCase); + if (Directory.Exists(folder)) before.UnionWith(Directory.GetFiles(folder)); + _folders.Add((folder, before)); + } + + /// A folder this operation is about to create. + public void WillCreateFolder(string folder) => _createdFolders.Add(folder); + + /// A file this operation is about to create (an archive that did not exist). + public void WillCreateFile(string path) => _createdFiles.Add(path); + + /// + /// Moves a folder out of the way instead of deleting it: it is put back if the operation fails and + /// removed by if it succeeds. Nothing happens when there is no such folder. + /// + public void MoveAside(string folder) + { + if (!Directory.Exists(folder)) return; + string aside = folder.TrimEnd(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar) + + ".aside-" + Guid.NewGuid().ToString("N")[..8]; + Directory.Move(folder, aside); + _movedAside.Add((folder, aside)); + } + + /// An archive that was just replaced, with the backup taken of what it held. + public void Replaced(string archive, string? backup) => _replaced.Add((archive, backup)); + + /// The operation succeeded: what was moved aside is no longer needed. + public void Commit() + { + foreach ((string _, string aside) in _movedAside) + { + try { SdsWriter.DeleteExtracted(aside); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // a leftover folder beside the working copy; it is named for what it is + } + } + _movedAside.Clear(); + } + + /// + /// Takes everything back, newest first. Returns what could NOT be put back, in words — an operation that + /// failed because a file is locked may well fail to restore that same file. + /// + public List Undo() + { + var problems = new List(); + void Try(string what, Action action) + { + try { action(); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + problems.Add($"{what}: {ex.Message}"); + } + } + + for (int i = _replaced.Count - 1; i >= 0; i--) + { + (string archive, string? backup) = _replaced[i]; + if (backup == null || !File.Exists(backup)) + { + problems.Add($"{Path.GetFileName(archive)} was replaced and there is no backup of it to restore"); + continue; + } + Try($"restoring {Path.GetFileName(archive)}", () => + { + File.Copy(backup, archive, overwrite: true); + File.Delete(backup); // the backup existed only because of this operation + }); + } + foreach (string file in Enumerable.Reverse(_createdFiles)) + { + Try($"removing {Path.GetFileName(file)}", () => + { + File.Delete(file); + DeleteTemporary(file); // what a pack leaves when its last move fails + }); + } + foreach ((string archive, string? _) in _replaced) + { + Try($"removing {Path.GetFileName(archive)}.tmp", () => DeleteTemporary(archive)); + } + foreach (string folder in Enumerable.Reverse(_createdFolders)) + { + Try($"removing {folder}", () => SdsWriter.DeleteExtracted(folder)); + } + for (int i = _movedAside.Count - 1; i >= 0; i--) + { + (string folder, string aside) = _movedAside[i]; + Try($"putting {folder} back", () => + { + SdsWriter.DeleteExtracted(folder); + if (Directory.Exists(aside)) Directory.Move(aside, folder); + }); + } + foreach ((string folder, HashSet before) in _folders) + { + if (!Directory.Exists(folder)) continue; + foreach (string file in Directory.GetFiles(folder)) + { + if (!before.Contains(file)) Try($"removing {Path.GetFileName(file)}", () => File.Delete(file)); + } + } + for (int i = _files.Count - 1; i >= 0; i--) + { + (string path, byte[]? bytes) = _files[i]; + Try($"restoring {Path.GetFileName(path)}", () => + { + if (bytes == null) File.Delete(path); + else AtomicFile.WriteAllBytes(path, bytes); + }); + } + return problems; + } + + // Only a FILE of that name is the pack's leftover. Whatever else stands there — the very thing that made + // the pack fail, perhaps — is not this operation's to remove, and not being able to is no failure of the undo. + private static void DeleteTemporary(string archive) + { + if (File.Exists(archive + ".tmp")) File.Delete(archive + ".tmp"); + } +} diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index fb92063..d8f2073 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -125,7 +125,7 @@ public interface IEditorSession /// Opens the resource editor (if it is not open) on an archive — a path, a path under pc\sds /// such as cars/shubert_38.sds, or a bare name — and makes it the target. Null when the load started. - string? OpenResource(string archive); + string? OpenResource(string archive, out string? archivePath); /// Archives of the game's library, by name fragment and/or folder fragment. IReadOnlyList Library(string? query, string? folder, int limit); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 1f496a6..ecdedbd 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -79,15 +79,35 @@ public static async Task ResourceOpen( { try { - if (await ui.RunAsync(() => editor.OpenResource(archive)) is { } refused) return ToolResult.Invalid(refused); + string? wanted = null; + if (await ui.RunAsync(() => editor.OpenResource(archive, out wanted)) is { } refused) return ToolResult.Invalid(refused); DateTime until = DateTime.UtcNow.AddSeconds(Math.Clamp(timeoutSeconds, 1, 600)); ResourceStatus status; + DateTime? emptySince = null; + bool staged; do { await Task.Delay(400); status = await ui.RunAsync(editor.ResourceStatus); + // "Something is loaded" is not "what was asked for is loaded": the stage keeps showing the + // previous archive until the new one takes its place, and that one passes every other test. + staged = string.Equals(status.ArchivePath, wanted, StringComparison.OrdinalIgnoreCase); + // An archive with nothing to draw (a texture pack) is on the stage with no meshes for good. + if (staged && !status.Loading && status.Meshes == 0) emptySince ??= DateTime.UtcNow; + else emptySince = null; + } + while ((!staged || status.Loading || (status.Meshes == 0 && DateTime.UtcNow - emptySince < TimeSpan.FromSeconds(4))) + && DateTime.UtcNow < until); + if (!staged) + { + return ToolResult.Json(new + { + success = false, + error = $"the resource editor did not put {Path.GetFileName(wanted)} on its stage — it shows " + + (status.Archive ?? "nothing") + ". The tools still act on what it shows", + resource = status, + }); } - while ((status.Loading || status.Meshes == 0) && DateTime.UtcNow < until); return ToolResult.Json(new { success = true, loaded = !status.Loading && status.Meshes > 0, resource = status }); } catch (Exception ex) diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 18aa364..50cdc32 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -175,6 +175,12 @@ public static bool TryRun(string[] args) return true; // A car cloned under a new name, on scratch copies of the working copies. // Output: %TEMP%\illusion_car_clone.txt + // The real clone against the install with a pack made to fail, and everything it wrote taken + // back. WRITES TO THE INSTALL (and restores it); the game must not be running. + // Output: %TEMP%\illusion_car_clone_rollback.txt + case "--probe-car-clone-rollback": + CarCloneProbes.RunCarCloneRollbackProbe(); + return true; case "--probe-car-clone": CarCloneProbes.RunCarCloneProbe(); return true; diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index 7d0a90c..c55e9b5 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -26,6 +26,140 @@ internal static class CarCloneProbes private const string Title = "Shubert 38 Clone"; // Output: %TEMP%\illusion_car_clone.txt + /// + /// The real against the install, made to fail — the one thing the scratch + /// probe above cannot do, since the clone finds its archives through the game folder. + /// + /// UNLIKE EVERY OTHER PROBE THIS ONE WRITES TO THE INSTALL: it clones shubert_38 under a probe name with + /// the winter archive's pack blocked, so the clone fails after the summer archive is built and the shared + /// tables' working copies are written — and then checks that all of it was taken back. The files it may + /// touch are snapshotted first and put back by force at the end whatever happens. The game must not be + /// running. Output: %TEMP%\illusion_car_clone_rollback.txt + /// + /// + internal static void RunCarCloneRollbackProbe() + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_clone_rollback.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)}"); + } + + const string Kept = "illusion_probe_kept", Failing = "illusion_probe_fail"; + var snapshot = new Dictionary(StringComparer.OrdinalIgnoreCase); + var leftovers = new List(); + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + string sds = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + var tablesSds = new FileInfo(Path.Combine(sds, "tables", "tables.sds")); + var ingameSds = new FileInfo(Path.Combine(sds, "tables", "ingame.sds")); + string tables = SdsMeshLoader.EnsureExtracted(tablesSds), ingame = SdsMeshLoader.EnsureExtracted(ingameSds); + List text = [.. Directory.GetDirectories(MafiaEnvironment.PcFolder, "sds_*") + .Select(d => new FileInfo(Path.Combine(d, "text", "text_default.sds"))).Where(f => f.Exists) + .Select(SdsMeshLoader.EnsureExtracted)]; + + // Everything a clone writes in the shared working copies, as it is now. + var watched = new List { Path.Combine(tables, "tables", "vehicles.tbl"), Path.Combine(ingame, "SDSContent.xml") }; + watched.AddRange(Directory.GetFiles(Path.Combine(ingame, "tables"), "*.tbl")); + watched.AddRange(text.Select(t => Path.Combine(t, "tables", "TextDatabase.dat")).Where(File.Exists)); + foreach (string file in watched) snapshot[file] = File.ReadAllBytes(file); + string aiProps = Path.Combine(ingame, "tables", "AiProps"); + HashSet propsBefore = Directory.Exists(aiProps) + ? new HashSet(Directory.GetFiles(aiProps), StringComparer.OrdinalIgnoreCase) : []; + (DateTime Tables, DateTime Ingame) packedAt = (tablesSds.LastWriteTimeUtc, ingameSds.LastWriteTimeUtc); + int backupsBefore = SdsWriter.ListBackups(tablesSds).Count + SdsWriter.ListBackups(ingameSds).Count; + + bool Untouched() => snapshot.All(f => File.Exists(f.Key) && File.ReadAllBytes(f.Key).AsSpan().SequenceEqual(f.Value)) + && (!Directory.Exists(aiProps) || Directory.GetFiles(aiProps).All(propsBefore.Contains)); + + // ── A working copy under the new name is somebody's: refused, and left as it is ── + string keptFolder = MafiaEnvironment.ExtractedDir(new FileInfo(Path.Combine(sds, "cars", Kept + ".sds"))); + leftovers.Add(keptFolder); + Directory.CreateDirectory(keptFolder); + File.WriteAllText(Path.Combine(keptFolder, "SDSContent.xml"), "work that was never built"); + // A title of 49 characters: the refusal that used to arrive AFTER the folder had been deleted. + CarCloneOutcome? refused = CarCloner.Clone("shubert_38", Kept, true, new string('a', 49), out string? why); + Check("a clone onto an existing working copy is refused", refused == null && why != null, why ?? ""); + Check("…and the working copy is still there, with what it held", + File.Exists(Path.Combine(keptFolder, "SDSContent.xml")) + && File.ReadAllText(Path.Combine(keptFolder, "SDSContent.xml")) == "work that was never built"); + Check("…and nothing else was written", Untouched()); + + // ── A clone that fails half-way takes itself back ── + // The winter archive cannot be packed: a FOLDER stands where its temporary file goes. + var summer = new FileInfo(Path.Combine(sds, "cars", Failing + ".sds")); + string blocker = Path.Combine(sds, "cars", Failing + "_z.sds.tmp"); + if (!File.Exists(Path.Combine(sds, "cars", "shubert_38_z.sds"))) + { + sb.AppendLine(" (shubert_38 has no winter archive here — the failure cannot be staged)"); + } + else + { + leftovers.Add(blocker); + leftovers.Add(summer.FullName); + leftovers.Add(MafiaEnvironment.ExtractedDir(summer)); + leftovers.Add(MafiaEnvironment.ExtractedDir(new FileInfo(Path.Combine(sds, "cars", Failing + "_z.sds")))); + Directory.CreateDirectory(blocker); + CarCloneOutcome? failed = CarCloner.Clone("shubert_38", Failing, true, "Probe car", out why); + Check("the clone fails when its winter archive cannot be packed, and says it was taken back", + failed == null && why != null && why.Contains("taken back", StringComparison.Ordinal), why ?? ""); + Check("it reports nothing left out of place", + why != null && !why.Contains("NOT everything", StringComparison.Ordinal), why ?? ""); + summer.Refresh(); + Check("the summer archive it had already built is gone", !summer.Exists); + Check("both clone folders are gone", + !Directory.Exists(MafiaEnvironment.ExtractedDir(summer)) + && !Directory.Exists(MafiaEnvironment.ExtractedDir(new FileInfo(Path.Combine(sds, "cars", Failing + "_z.sds"))))); + Check("vehicles.tbl, the paint and traffic tables, the cover points, the manifest and the text are as they were", + Untouched()); + tablesSds.Refresh(); + ingameSds.Refresh(); + Check("tables.sds and ingame.sds were never packed, and no backup of them was left", + tablesSds.LastWriteTimeUtc == packedAt.Tables && ingameSds.LastWriteTimeUtc == packedAt.Ingame + && SdsWriter.ListBackups(tablesSds).Count + SdsWriter.ListBackups(ingameSds).Count == backupsBefore); + + // The point of taking it back: the same clone can be tried again (and is refused only by + // the block that is still standing, not by "already exists"). + CarCloneOutcome? again = CarCloner.Clone("shubert_38", Failing, true, "Probe car", out why); + Check("the same clone can be tried again — it is not refused as already existing", + again == null && why != null && !why.Contains("already", StringComparison.Ordinal), why ?? ""); + Check("…and the second failure leaves nothing behind either", Untouched()); + } + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + // Whatever the checks said, the install is put back by force. + int forced = 0; + foreach ((string file, byte[] bytes) in snapshot) + { + if (File.Exists(file) && File.ReadAllBytes(file).AsSpan().SequenceEqual(bytes)) continue; + File.WriteAllBytes(file, bytes); + forced++; + } + foreach (string path in leftovers) + { + try + { + if (Directory.Exists(path)) Directory.Delete(path, recursive: true); + else if (File.Exists(path)) File.Delete(path); + } + catch (IOException) { sb.AppendLine(" could not remove " + path); } + } + if (forced > 0) sb.AppendLine($" {forced} shared file(s) had to be put back by the probe itself"); + sb.Insert(0, $"CAR CLONE ROLLBACK PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + internal static void RunCarCloneProbe() { string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_clone.txt"); @@ -329,6 +463,68 @@ string Describe(SdsArchive a, ResourceEntry e) => Check($"{Path.GetFileName(folder)}: a Build drops no manifest entry", pruned.Count == 0, string.Join(", ", pruned.Take(5))); } + + // ════ An operation that writes several files takes itself back ════ + // The journal on scratch files: each kind of write a clone or a substitution makes, then undone. + { + string lab = Path.Combine(scratch, "journal"); + string props = Path.Combine(lab, "AiProps"), work = Path.Combine(lab, "target.sds"), made = Path.Combine(lab, "clone.sds"); + Directory.CreateDirectory(props); + Directory.CreateDirectory(work); + string kept = Path.Combine(lab, "vehicles.tbl"), absent = Path.Combine(lab, "new.tbl"); + string archive = Path.Combine(lab, "tables.sds"), backup = archive + ".backup", madeFile = Path.Combine(lab, "clone_archive.sds"); + File.WriteAllBytes(kept, [1, 2, 3]); + File.WriteAllText(Path.Combine(props, "old.xml"), "old"); + File.WriteAllBytes(archive, [9, 9, 9]); + File.WriteAllText(Path.Combine(work, "SDSContent.xml"), "work that was never built"); + + var journal = new GameWriteJournal(); + journal.Remember(kept); + journal.Remember(absent); + journal.RememberContents(props); + journal.WillCreateFolder(made); + journal.WillCreateFile(madeFile); + journal.MoveAside(work); + journal.WillCreateFolder(work); + Check("a folder moved aside is out of the way, not gone", + !Directory.Exists(work) && Directory.GetDirectories(lab, "target.sds.aside-*").Length == 1); + + // …what the operation then does, up to the step that fails. + File.WriteAllBytes(kept, [7]); + File.WriteAllBytes(absent, [7]); + File.WriteAllText(Path.Combine(props, "new.xml"), "new"); + Directory.CreateDirectory(made); + File.WriteAllText(Path.Combine(made, "SDSContent.xml"), "clone"); + File.WriteAllBytes(madeFile, [5]); + File.WriteAllBytes(madeFile + ".tmp", [5]); + Directory.CreateDirectory(work); + File.WriteAllText(Path.Combine(work, "SDSContent.xml"), "substituted"); + File.Copy(archive, backup); + File.WriteAllBytes(archive, [4, 4]); + journal.Replaced(archive, backup); + + List problems = journal.Undo(); + Check("taking it back reports nothing left out of place", problems.Count == 0, string.Join("; ", problems)); + Check("a rewritten file has its bytes back, and one that did not exist is gone", + File.ReadAllBytes(kept).AsSpan().SequenceEqual(new byte[] { 1, 2, 3 }) && !File.Exists(absent)); + Check("a file added to a remembered folder is removed, the ones it had are kept", + !File.Exists(Path.Combine(props, "new.xml")) && File.Exists(Path.Combine(props, "old.xml"))); + Check("a created folder and a created archive are removed, the half-written .tmp with it", + !Directory.Exists(made) && !File.Exists(madeFile) && !File.Exists(madeFile + ".tmp")); + Check("a replaced archive is restored from its backup, and the backup does not stay behind", + File.ReadAllBytes(archive).AsSpan().SequenceEqual(new byte[] { 9, 9, 9 }) && !File.Exists(backup)); + Check("the folder moved aside is back with what it held", + File.Exists(Path.Combine(work, "SDSContent.xml")) + && File.ReadAllText(Path.Combine(work, "SDSContent.xml")) == "work that was never built" + && Directory.GetDirectories(lab, "target.sds.aside-*").Length == 0); + + var kept2 = new GameWriteJournal(); + kept2.MoveAside(work); + Directory.CreateDirectory(work); + kept2.Commit(); + Check("on success the folder moved aside is dropped", + Directory.Exists(work) && Directory.GetDirectories(lab, "target.sds.aside-*").Length == 0); + } } catch (Exception ex) { diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 5f3c1c6..b005855 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -96,8 +96,9 @@ public ResourceStatus ResourceStatus() : "the game folder is not set yet — open the toolkit's launcher first"; } - public string? OpenResource(string archive) + public string? OpenResource(string archive, out string? archivePath) { + archivePath = null; if (EnsureEnvironment() is { } notReady) return notReady; var sds = new FileInfo(Path.IsPathRooted(archive) ? archive @@ -132,6 +133,7 @@ public ResourceStatus ResourceStatus() if (window.TargetStage is { } stage && stage.BridgeEditedCount > 0) return "a Blender edit session is open there — blender_end first"; window.Reveal(sds); _resourceTarget = true; + archivePath = sds.FullName; return null; } diff --git a/src/Illusion/Views/ResourceEditorWindow.xaml.cs b/src/Illusion/Views/ResourceEditorWindow.xaml.cs index 9e90858..18e683f 100644 --- a/src/Illusion/Views/ResourceEditorWindow.xaml.cs +++ b/src/Illusion/Views/ResourceEditorWindow.xaml.cs @@ -123,14 +123,32 @@ public void Reveal(FileInfo archive) { if (_catalog?.Find(archive) is { } entry) { + _rebuiltFor = null; Browser.Reveal(entry); StageEntry(entry); return; } _pendingReveal = archive; + + // The catalog is a walk of the game's folders made when the window opened. An archive made since — a + // car cloned a moment ago — is not in it, and the request used to wait here for good while the stage + // went on showing the previous car. Walk again, once per archive: one that is still not found after + // that is simply not in the library, and asking again would only loop. + if (_catalog != null && archive.Exists + && !string.Equals(_rebuiltFor, archive.FullName, StringComparison.OrdinalIgnoreCase)) + { + _rebuiltFor = archive.FullName; + _catalog = null; + BuildCatalog(); + } + else if (_catalog != null) + { + _pendingReveal = null; + } } private FileInfo? _pendingReveal; + private string? _rebuiltFor; // ── Library ── From 56f5f8d7df6a3d0a8d31994a3f43a0a6bd14a876 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:29:18 +0300 Subject: [PATCH 048/102] fix(import): a failed import leaves nothing behind; redo gets its shared block and its textures back Found reviewing this branch the way #3-#5 were reviewed. An import that failed after the frames were copied left them in the scene. The copy is made first, then its textures are carried, its rows built and its collision read; a failure in any of those answered "the import failed" with the frames still in the resource - in the next save, with no row in the tree to delete them by and no undo entry. The catch also named five exception types, so a collision file that does not decode went past it as a tool error, same leftovers. Import now takes the copy back out when it fails before the edit is on the undo stack, catches whatever is thrown, and says in the refusal that nothing was left. Edits of one import that were already applied when a later one throws are undone again; an actor already in the pack is removed; GPU meshes made for rows that will never be shown are released. A failure AFTER the edit is registered (writing the note that lets the next import share geometry) no longer reports the import as failed. Redo of a copy that shares a geometry block threw on the next save. A second import of the same object draws from the first one's block and lists none of its own. With the first copy deleted and the second import undone, a save prunes the block; Reattach put back only the blocks the copy lists, so the redone mesh named a block the resource did not have and the save after it died in UpdateFrameData (KeyNotFoundException). Reattach now also re-registers what its meshes point at, as DetachedFrames does. A save swept textures that undo could still bring back into use. Carried textures no material of the scene names are removed at save - but the import can be redone and a deleted object's delete undone, and neither carries anything again: the object came back naming textures that were gone from the working copy and its manifest. Swept textures are now parked - out of the manifest, into illusion_parked\ under a name no scan for *.dds answers to, with their manifest entries kept field for field - and the next sweep that finds the scene naming one puts it back. What an earlier run of the program parked is dropped at the first save of the next; a texture carried again while parked wins over the parked copy. A file that cannot be moved leaves the texture where it is for the next save instead of failing the save. --probe-object-transplant hill shops\harry.sds 50/50 (12 new): sweep with the objects in / undone / saved twice / redone (same bytes, same manifest entry, MIP companion with it) / swept again / carried again while parked / left by an ended run; the shared block after delete + undo + save + redo, saved and read back. Without the Reattach change the same probe dies with the KeyNotFoundException. Live, against the editor: a source whose collision file is cut short - before the change every later import under that name answers "already taken"; after it the same refusal twice and no rows. A scenery import (250 hulls) and an actor import still work, with undo, redo, undo. Not checked: the failure paths inside ImportPlaced after the pack took the actor, and a failing GPU mesh. --- src/Illusion.Assets/Frames/FrameTransplant.cs | 18 ++ src/Illusion.Assets/Sds/ArchiveCarry.cs | 190 +++++++++++++++++- .../Probes/ObjectTransplantProbes.cs | 132 ++++++++++++ .../Viewport/ObjectImportController.cs | 91 ++++++++- 4 files changed, 411 insertions(+), 20 deletions(-) diff --git a/src/Illusion.Assets/Frames/FrameTransplant.cs b/src/Illusion.Assets/Frames/FrameTransplant.cs index a919003..8a58cb1 100644 --- a/src/Illusion.Assets/Frames/FrameTransplant.cs +++ b/src/Illusion.Assets/Frames/FrameTransplant.cs @@ -135,6 +135,24 @@ public void Reattach() if (!Resource.FrameMaterials.ContainsKey(material.RefID)) Resource.FrameMaterials.Add(material.RefID, material); } + // And the blocks its meshes point at without owning them. A second import of the same object + // draws from the first one's geometry block (CopyBlocks) and lists none of its own; with the + // first copy deleted and this one undone, a save prunes the block as unused — and a redo that + // put back only what is listed above left a mesh naming a block the resource no longer had, + // which the next save writes as a mesh index pointing nowhere. + foreach (FrameObjectSingleMesh mesh in Frames.OfType()) + { + if (mesh.Refs.ContainsKey(FrameEntryRefTypes.Geometry) && mesh.Geometry is { } drawn + && !Resource.FrameGeometries.ContainsKey(drawn.RefID)) + { + Resource.FrameGeometries.Add(drawn.RefID, drawn); + } + if (mesh.Refs.ContainsKey(FrameEntryRefTypes.Material) && mesh.Material is { } worn + && !Resource.FrameMaterials.ContainsKey(worn.RefID)) + { + Resource.FrameMaterials.Add(worn.RefID, worn); + } + } foreach (VertexBuffer vb in VertexBuffers) { Resource.VertexBuffers.TryAddToPool(vb); diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index 5ead2ad..80c9c54 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -27,6 +27,12 @@ namespace Illusion.Assets.Sds; /// what this class brought is ever swept: an archive's own textures may be named by things a scene does not /// show (effects, decals, scripts) and are never touched. /// +/// +/// Swept is not deleted. The import that brought a texture can still be redone, and a deleted object's +/// delete undone — neither carries anything a second time — so a swept texture is taken out of the manifest +/// and parked in , and the next sweep that finds the scene naming it again puts it +/// back. What an earlier run of the program parked has no undo stack left to return to and is dropped then. +/// /// public static class ArchiveCarry { @@ -215,17 +221,139 @@ private static void Remember(string dir, string texture) if (names.Add(texture)) WriteRegister(dir, names); } + // Inside the working copy and outside its manifest, under a name no texture scan answers to (they look + // for *.dds): what is parked is on nobody's list until it is put back. + internal const string ParkedFolder = "illusion_parked"; + private const string ParkedIndex = "parked.json"; + private const string ParkedSuffix = ".parked"; + + /// One file of a parked texture — the texture itself or its MIP companion — with the manifest + /// entry it had, field for field, so that it can be announced again exactly as it was. + public sealed class ParkedFile + { + public string File { get; set; } = ""; + public List Fields { get; set; } = []; + } + + // What THIS run of the program parked, as "folder|texture". Only these can still be asked for by an undo + // stack; anything else found parked is left from a session that has ended. + private static readonly HashSet ParkedHere = new(StringComparer.OrdinalIgnoreCase); + + private static string ParkedKey(string dir, string texture) => Path.GetFullPath(dir) + "|" + texture; + + private static string ParkedPath(string dir, string file) => Path.Combine(dir, ParkedFolder, file + ParkedSuffix); + + /// The textures parked beside , by name (for the probes). + public static IReadOnlyCollection ParkedIn(string extracted) => ReadParked(extracted).Keys; + + /// Forgets which textures this run parked, as a restart of the program would (for the probes). + internal static void ForgetParkedHere() + { + lock (ParkedHere) ParkedHere.Clear(); + } + + private static Dictionary> ReadParked(string dir) + { + string path = Path.Combine(dir, ParkedFolder, ParkedIndex); + var empty = new Dictionary>(StringComparer.OrdinalIgnoreCase); + try + { + if (!File.Exists(path)) return empty; + Dictionary>? read = + System.Text.Json.JsonSerializer.Deserialize>>(File.ReadAllText(path)); + return read == null ? empty : new Dictionary>(read, StringComparer.OrdinalIgnoreCase); + } + catch (Exception ex) when (ex is IOException or System.Text.Json.JsonException or UnauthorizedAccessException) + { + return empty; + } + } + + private static void WriteParked(string dir, Dictionary> parked) + { + string folder = Path.Combine(dir, ParkedFolder); + if (parked.Count == 0) + { + if (Directory.Exists(folder)) Directory.Delete(folder, recursive: true); + return; + } + Directory.CreateDirectory(folder); + AtomicFile.WriteAllBytes(Path.Combine(folder, ParkedIndex), System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(parked)); + } + + // Out of the manifest and into the parked folder. The copy is made BEFORE the entry is dropped and the + // original removed after it: whichever step fails, the manifest never names a file that is not there + // (which fails a Build), and the texture is never in neither place. + private static List Park(SdsManifest manifest, string dir, string texture, List dropped) + { + var files = new List(); + foreach (string file in new[] { texture, SdsImportTypes.MipNameFor(texture) }) + { + string path = Path.Combine(dir, file); + IReadOnlyList<(string Name, string Value)>? fields = manifest.EntryFields(file); + if (fields != null && File.Exists(path)) + { + string parkedPath = ParkedPath(dir, file); + Directory.CreateDirectory(Path.GetDirectoryName(parkedPath)!); + File.Copy(path, parkedPath, overwrite: true); + files.Add(new ParkedFile { File = file, Fields = [.. fields.Select(f => new[] { f.Name, f.Value })] }); + } + if (manifest.RemoveEntry(file)) dropped.Add(file); + File.Delete(path); + } + return files; + } + + // Back where it was, announced as it was. False when a file of it could not be put back — it stays + // parked and the next sweep tries again. + private static bool Unpark(SdsManifest manifest, string dir, List files) + { + bool whole = true; + foreach (ParkedFile parked in files) + { + string parkedPath = ParkedPath(dir, parked.File); + // Brought again since by another import: that copy is the one in use. + if (!manifest.HasFile(parked.File)) + { + List fields = parked.Fields; + if (!File.Exists(parkedPath) || fields.Count < 3 || fields.Any(f => f.Length != 2) + || fields[^1][0] != "Version" || !int.TryParse(fields[^1][1], out int version)) + { + whole = false; + continue; + } + File.Copy(parkedPath, Path.Combine(dir, parked.File), overwrite: true); + if (!manifest.AddEntry(fields[0][1], parked.File, version, + [.. fields.Skip(2).Take(fields.Count - 3).Select(f => (f[0], f[1]))])) + { + whole = false; + continue; + } + } + File.Delete(parkedPath); + } + return whole; + } + + private static void DropParked(string dir, List files) + { + foreach (ParkedFile parked in files) File.Delete(ParkedPath(dir, parked.File)); + } + /// - /// Drops the textures this class carried into that no material of - /// names — left behind by an import that was undone, or by a scene closed without - /// saving it. Their MIP companions go with them. Returns what was dropped. + /// Takes the textures this class carried into that no material of + /// names out of the working copy — left behind by an import that was undone, or by + /// a scene closed without saving it — and puts back the ones taken out earlier that the scene names again: + /// an import redone, a delete undone. Their MIP companions go and come with them. A texture taken out is + /// parked, not deleted, for as long as this run of the program lasts. Returns the manifest entries taken out. /// public static IReadOnlyList SweepUnused(string extracted, Formats.Frames.FrameResource scene) { ArgumentException.ThrowIfNullOrEmpty(extracted); ArgumentNullException.ThrowIfNull(scene); HashSet carried = ReadRegister(extracted); - if (carried.Count == 0) return []; + Dictionary> parked = ReadParked(extracted); + if (carried.Count == 0 && parked.Count == 0) return []; MafiaMaterials.EnsureLoaded(); var named = new HashSet(StringComparer.OrdinalIgnoreCase); @@ -245,15 +373,57 @@ public static IReadOnlyList SweepUnused(string extracted, Formats.Frames SdsManifest manifest = SdsManifest.Load(extracted); var dropped = new List(); - foreach (string texture in carried.ToList()) + lock (ParkedHere) { - if (named.Contains(texture)) continue; - foreach (string file in new[] { texture, SdsImportTypes.MipNameFor(texture) }) + // Back first: what the scene names again. A file that cannot be moved this time (the viewport, or + // the game, holding it) leaves the texture where it is, to be tried at the next save — a sweep is + // housekeeping and must not fail the save it runs in. + foreach ((string texture, List files) in parked.ToList()) + { + try + { + if (named.Contains(texture)) + { + if (!Unpark(manifest, extracted, files)) continue; + carried.Add(texture); + TextureSearchIndex.Register(Path.Combine(extracted, texture)); + } + else if (ParkedHere.Contains(ParkedKey(extracted, texture))) + { + continue; // an undo stack of this run can still bring its object back + } + else + { + DropParked(extracted, files); + } + parked.Remove(texture); + ParkedHere.Remove(ParkedKey(extracted, texture)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // stays parked + } + } + + foreach (string texture in carried.ToList()) { - if (manifest.RemoveEntry(file)) dropped.Add(file); - File.Delete(Path.Combine(extracted, file)); + if (named.Contains(texture)) continue; + try + { + List files = Park(manifest, extracted, texture, dropped); + if (files.Count > 0) + { + parked[texture] = files; + ParkedHere.Add(ParkedKey(extracted, texture)); + } + carried.Remove(texture); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // stays carried + } } - carried.Remove(texture); + WriteParked(extracted, parked); } WriteRegister(extracted, carried); return dropped; diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 7978e83..8847164 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -252,6 +252,91 @@ [.. all.SelectMany(t => t.VertexBuffers).Select(b => b.Hash)], && prefab.ToBytes().AsSpan().SequenceEqual(prefabBytes), $"{BitConverter.ToInt32(prefabBytes, 0)} + 4 against {prefabBytes.Length}"); + // ── Carried textures and undo: what a save sweeps is parked, and comes back with its object ── + ArchiveCarry.Report sceneryCarry = ArchiveCarry.Carry(sourceDir, dir, carriedScenery.MaterialHashes, [], null); + string[] brought = [.. doorCarry.Textures.Concat(propCarry.Textures).Concat(sceneryCarry.Textures) + .Distinct(StringComparer.OrdinalIgnoreCase)]; + bool InUse(string texture) => SdsManifest.Load(dir).HasFile(texture) && File.Exists(Path.Combine(dir, texture)); + bool Gone(string texture) => !SdsManifest.Load(dir).HasFile(texture) && !File.Exists(Path.Combine(dir, texture)); + bool Parked(string texture) => ArchiveCarry.ParkedIn(dir).Contains(texture, StringComparer.OrdinalIgnoreCase); + void TakeOut() + { + carriedScenery.Detach(); + carriedProp.Detach(); + carriedDoor.Detach(); + } + void PutBack() + { + carriedDoor.Reattach(); + carriedProp.Reattach(); + carriedScenery.Reattach(); + } + Check("the three objects brought textures the district did not have", brought.Length > 0 && brought.All(InUse), + $"{brought.Length} texture(s)"); + if (brought.Length > 0) + { + byte[][] bytesBefore = [.. brought.Select(t => File.ReadAllBytes(Path.Combine(dir, t)))]; + (string, string)[][] entriesBefore = [.. brought.Select(t => SdsManifest.Load(dir).EntryFields(t)!.ToArray())]; + bool[] withMip = [.. brought.Select(t => SdsManifest.Load(dir).HasFile(SdsImportTypes.MipNameFor(t)))]; + + ArchiveCarry.SweepUnused(dir, ours); + Check("a save with the objects in the scene sweeps none of them", brought.All(InUse) && !brought.Any(Parked)); + + TakeOut(); + IReadOnlyList sweptOut = ArchiveCarry.SweepUnused(dir, ours); + Check("the imports undone, a save takes their textures out of the manifest and the folder", + brought.All(Gone) && brought.All(t => sweptOut.Contains(t, StringComparer.OrdinalIgnoreCase)), + $"{sweptOut.Count} manifest entr(ies) out"); + Check("parked, not deleted — and under a name no scan for textures answers to", + brought.All(Parked) + && !Directory.GetFiles(dir, "*.dds", SearchOption.AllDirectories) + .Any(f => brought.Contains(Path.GetFileName(f), StringComparer.OrdinalIgnoreCase))); + ArchiveCarry.SweepUnused(dir, ours); + Check("a second save while they are undone leaves them parked", brought.All(Gone) && brought.All(Parked)); + + PutBack(); + ArchiveCarry.SweepUnused(dir, ours); + bool sameBytes = true, sameEntries = true, mipBack = true; + for (int i = 0; i < brought.Length && brought.All(InUse); i++) + { + sameBytes &= File.ReadAllBytes(Path.Combine(dir, brought[i])).AsSpan().SequenceEqual(bytesBefore[i]); + sameEntries &= SdsManifest.Load(dir).EntryFields(brought[i])!.SequenceEqual(entriesBefore[i]); + string mip = SdsImportTypes.MipNameFor(brought[i]); + mipBack &= SdsManifest.Load(dir).HasFile(mip) == withMip[i] && File.Exists(Path.Combine(dir, mip)) == withMip[i]; + } + Check("redone, the next save puts every one back — the same bytes, the same manifest entry, its MIP companion with it", + brought.All(InUse) && sameBytes && sameEntries && mipBack && !brought.Any(Parked) + && !Directory.Exists(Path.Combine(dir, ArchiveCarry.ParkedFolder)), + $"bytes {sameBytes}, entries {sameEntries}, companions {mipBack} ({withMip.Count(m => m)} have one)"); + Check("and the carried register knows them again, so a later undo sweeps them again", + Sweeps(TakeOut, PutBack, dir, ours, brought, Gone, InUse)); + + // Brought a second time while parked — the same object imported again after the undo. + TakeOut(); + ArchiveCarry.SweepUnused(dir, ours); + ArchiveCarry.Report second = ArchiveCarry.Carry(sourceDir, dir, + [.. carriedDoor.MaterialHashes, .. carriedProp.MaterialHashes, .. carriedScenery.MaterialHashes], [], null); + PutBack(); + ArchiveCarry.SweepUnused(dir, ours); + Check("a texture carried again while it was parked is the one in use, and the parked copy is dropped", + second.Textures.Count == brought.Length && brought.All(InUse) && !brought.Any(Parked) + && !Directory.Exists(Path.Combine(dir, ArchiveCarry.ParkedFolder)), + $"{second.Textures.Count} carried again"); + + // What an earlier run of the program parked has no undo stack to go back to. + TakeOut(); + ArchiveCarry.SweepUnused(dir, ours); + bool parkedThen = brought.All(Parked); + ArchiveCarry.ForgetParkedHere(); + ArchiveCarry.SweepUnused(dir, ours); + Check("parked textures left by a run that has ended are dropped at the first save of the next", + parkedThen && brought.All(Gone) && !brought.Any(Parked) + && !Directory.Exists(Path.Combine(dir, ArchiveCarry.ParkedFolder))); + PutBack(); + ArchiveCarry.Carry(sourceDir, dir, + [.. carriedDoor.MaterialHashes, .. carriedProp.MaterialHashes, .. carriedScenery.MaterialHashes], [], null); + } + // ── A destination that has neither a prefab nor an item description of its own ── string bare = Path.Combine(scratch, "bare"); Directory.CreateDirectory(bare); @@ -288,6 +373,40 @@ static List Drawn(TransplantedObject t) => FrameTransplant.Standing.Prototype, Matrix4x4.Identity, ImportGeometry.Load(dir, sourceArchive), out reason); Check("what was shared is remembered on disk, for the next session", three is { VertexBuffers.Count: 0 }); three?.Detach(); + + // ── Redo of a copy that draws from a block it does not own, once a save has pruned that block ── + // The first copy deleted (a delete keeps the blocks registered), the second import undone, a save + // (nothing draws from the shared block now, and the resource prunes it), then the import redone. + DetachedFrames? firstDeleted = DetachedFrames.Capture(document, + [.. one.Pairs.Values.Select(f => (Domain.IFrameNode)document.Node(f))], dir); + Check("the first copy can be deleted the way the editor deletes", firstDeleted != null); + if (firstDeleted != null) + { + firstDeleted.Detach(); + two.Detach(); + ours.WriteToStream(); + bool pruned = Drawn(two).All(m => !ours.FrameGeometries.ContainsKey(m.Geometry.RefID)); + two.Reattach(); + bool registered = Drawn(two).All(m => ours.FrameGeometries.ContainsKey(m.Geometry.RefID) + && (!m.Refs.ContainsKey(FrameEntryRefTypes.Material) || ours.FrameMaterials.ContainsKey(m.Material.RefID))); + byte[] redone = ours.WriteToStream(); + var live = ours.FrameObjects.Values.ToList(); + var readBack = new FrameResource(); + using (var stream = new MemoryStream(redone)) readBack.ReadFromFile(stream); + var read = readBack.FrameObjects.Values.ToList(); + int sound = Drawn(two).Count(m => live.IndexOf(m) is var at and >= 0 && at < read.Count + && read[at] is FrameObjectSingleMesh r && r.Geometry?.LOD is { Length: > 0 } lods + && lods.Length == m.Geometry.LOD.Length + && lods[0].VertexBufferRef.Hash == m.Geometry.LOD[0].VertexBufferRef.Hash + && lods[0].IndexBufferRef.Hash == m.Geometry.LOD[0].IndexBufferRef.Hash); + Check("redone after a save pruned the block it shares, the copy has its geometry block registered again", + pruned && registered, $"pruned while undone: {pruned}, back on redo: {registered}"); + Check("and saved and read back, each of its meshes still draws from the buffers it drew from", + Drawn(two).Count > 0 && sound == Drawn(two).Count, $"{sound} of {Drawn(two).Count}"); + two.Detach(); + firstDeleted.Reattach(); + two.Reattach(); + } two.Detach(); Check("taking the copies out leaves the first one's geometry in place", one.VertexBuffers.All(b => ours.VertexBuffers.GetBuffer(b.Hash) != null) @@ -466,6 +585,19 @@ private static bool Encloses((Vector3[] Vertices, int[] Triangles) hull, IReadOn return true; } + // Whether the textures leave again with their objects and come back again — that is, whether putting a + // parked texture back also put it back on the list of what a sweep may take. + private static bool Sweeps(Action takeOut, Action putBack, string dir, FrameResource scene, string[] textures, + Func gone, Func inUse) + { + takeOut(); + ArchiveCarry.SweepUnused(dir, scene); + bool left = textures.All(gone); + putBack(); + ArchiveCarry.SweepUnused(dir, scene); + return left && textures.All(inUse); + } + private static string CopyWithoutTextures(string from, string to) { Directory.CreateDirectory(to); diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index 14f7b09..e41b34b 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -34,12 +34,39 @@ internal sealed class ObjectImportController public SceneNode ImportScenery(SceneNode frameRow, TransplantedObject carried, IReadOnlyList also) { var edit = new ImportObjectEdit(this, frameRow, carried, BuildRows(frameRow, carried), actor: null); - edit.Redo(); - foreach (Domain.IEditAction extra in also) extra.Redo(); + Apply([edit, .. also]); _host.History.Push(also.Count == 0 ? edit : new CompositeEdit([edit, .. also])); return edit.RootRow; } + // Applies the edits of one import. If one of them throws, the ones before it are undone again: the + // import is not on the undo stack yet, so nothing else could ever take half of it back out of the scene. + // (What the failing edit itself got done is the caller's to clear — Import takes the copied frames out.) + private static void Apply(IReadOnlyList edits) + { + int done = 0; + try + { + for (; done < edits.Count; done++) edits[done].Redo(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + for (int i = done - 1; i >= 0; i--) + { + try + { + edits[i].Undo(); + edits[i].Discard(); + } + catch (Exception undoing) when (undoing is not OutOfMemoryException) + { + // the first failure is the one worth reporting + } + } + throw; + } + } + /// /// Takes the actor that places a carried object into the receiving archive's pack and shows both. Null /// with a reason when the pack refuses the actor — the carried object is then taken back out, since an @@ -58,10 +85,14 @@ public SceneNode ImportScenery(SceneNode frameRow, TransplantedObject carried, I return null; } ActorsFile pack = document.Placements.Packs[0].Pack; + // The rows and their GPU meshes first: the one step here that can fail for reasons outside the data, + // taken while the pack is still untouched. + Rows rows = BuildRows(frameRow, carried); ActorEntry? copy = pack.Import(sourcePack, source, name, position, rotation, new ActorPlacedFrame(carried.Root.Name.String, carried.FrameIndex), out reason); if (copy == null) { + foreach ((SceneNode _, GpuMesh mesh) in rows.Meshes) mesh.Dispose(); carried.Detach(); return null; } @@ -75,8 +106,17 @@ public SceneNode ImportScenery(SceneNode frameRow, TransplantedObject carried, I section ??= new SceneNode(adapter.TypeName, "Actors", true); var placed = new PlacedActor(copy, node, section, newSection ? actorsRow : null, document, pack); - var edit = new ImportObjectEdit(this, frameRow, carried, BuildRows(frameRow, carried), placed); - edit.Redo(); + var edit = new ImportObjectEdit(this, frameRow, carried, rows, placed); + try + { + edit.Redo(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Not on the undo stack, so nothing else would ever take the actor back out of the pack. + if (pack.Actors.Contains(copy)) pack.Remove(copy); + throw; + } _host.History.Push(edit); return node; } @@ -113,6 +153,12 @@ public SceneNode ImportScenery(SceneNode frameRow, TransplantedObject carried, I return "that is the loaded archive itself — duplicate the object instead (scene_duplicate_selected)"; } + // The copied frames, from the moment they are in the scene until the import is on the undo stack. A + // failure in that window — a file of the source that cannot be read, the working copy refusing a + // write — used to answer "the import failed" and leave them there: in the scene and in the next save, + // with no row in the tree to delete them by and no undo entry to take them back. + Assets.Frames.FrameTransplant.TransplantedObject? carried = null; + bool registered = false; try { string sourceDir = Assets.Sds.SdsMeshLoader.EnsureExtracted(sds); @@ -126,7 +172,6 @@ public SceneNode ImportScenery(SceneNode frameRow, TransplantedObject carried, I Formats.Actors.ActorEntry? actor = theirPlacements.All.FirstOrDefault( a => string.Equals(a.EntityName, name, StringComparison.OrdinalIgnoreCase)); - Assets.Frames.FrameTransplant.TransplantedObject? carried; Assets.Sds.ArchiveCarry.Report carry; string kind; string? reason; @@ -162,6 +207,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO { return reason ?? "the pack refused the actor"; } + registered = true; // A door is often opened by its own building's script rather than by the player — a shop's front // door has its actions switched off and the shop turns them on in opening hours. Carried out of // that building nothing turns them on, so the copy's own row lets the player open it. @@ -199,10 +245,20 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO carry = Carry(carried); ImportScenery(frameRow, carried, SceneryCollision(destination, source, frame, sourceWorld, carried, hulls, out collision)); + registered = true; kind = "scenery"; } - shared.Save(); + try + { + shared.Save(); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Only the note that lets the next import of this object draw from this one's geometry. Without + // it that import copies the geometry again — not a reason to call this one failed, which by + // now it is not. + } _host.MarkArchiveModified(destination); outcome = new ObjectImportOutcome(kind, newName, carried.Pairs.Count, carried.Renderables.Count, @@ -218,10 +274,16 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO isError: carry.Unresolved.Count > 0); return null; } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or InvalidDataException - or InvalidOperationException or Formats.SdsFormatException) + catch (Exception ex) when (ex is not OutOfMemoryException) { - return "the import failed: " + ex.Message; + if (registered) + { + // Everything that makes the import an import is done and on the undo stack; what failed is + // what follows it. Saying "failed" here would have the caller import it a second time. + return $"'{newName}' was imported and is in the scene (undo takes it out), but finishing up failed: {ex.Message}"; + } + if (carried is { IsAttached: true }) carried.Detach(); + return "the import failed and nothing of it was left in the scene: " + ex.Message; } } @@ -353,7 +415,16 @@ private Rows BuildRows(SceneNode frameRow, TransplantedObject carried) var all = new List<(FrameObjectBase, SceneNode)>(); var meshes = new List<(SceneNode, GpuMesh)>(); - SceneNode root = BuildRow(carried.Root, document, meshByFrame, all, meshes, new HashSet()); + SceneNode root; + try + { + root = BuildRow(carried.Root, document, meshByFrame, all, meshes, new HashSet()); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + foreach ((SceneNode _, GpuMesh mesh) in meshes) mesh.Dispose(); // made, and never to be shown + throw; + } // 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. From e95833761aa1eb03bdf061fd5a694771ab341611 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 22:31:03 +0300 Subject: [PATCH 049/102] test(import): the carried-again check asks for the textures, not for their count Higher in the stack a texture can also be borrowed from a third archive, so the second carry brings more than the three objects brought the first time. --- src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 8847164..6758276 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -319,7 +319,8 @@ void PutBack() PutBack(); ArchiveCarry.SweepUnused(dir, ours); Check("a texture carried again while it was parked is the one in use, and the parked copy is dropped", - second.Textures.Count == brought.Length && brought.All(InUse) && !brought.Any(Parked) + brought.All(t => second.Textures.Contains(t, StringComparer.OrdinalIgnoreCase)) && brought.All(InUse) + && !brought.Any(Parked) && !Directory.Exists(Path.Combine(dir, ArchiveCarry.ParkedFolder)), $"{second.Textures.Count} carried again"); From 45236da870c2fe0de2d105d431574601ff6c354d Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:06:14 +0300 Subject: [PATCH 050/102] fix(import): the lesser findings of the self-review - carry, links, names, numbers, thumbnails The second half of the review of this branch. One commit because the pieces lean on each other; by finding: Carry. A refused or failed import now takes its carry back: the working copy's manifest, prefab containers and carried register are noted before the carry and restored, and the files the manifest gained are removed (ArchiveCarry.Note / TakeBack). It used to leave textures, item descriptions and a prefab entry behind for an object that never arrived - the pack refuses an actor only after the carry. The texture a mesh names itself (OMTextureHash) is carried, and counted as in use by the save-time sweep. A MIP companion is copied before the texture that needs it, and a texture whose companion cannot be brought is not brought. RemoveEntry writes the manifest through a temp file, as AddEntry did. Object-to-hull link. The hulls an imported object was given are found by distance to the object's box, not its pivot - a district mesh's pivot can be metres from its geometry, and its hulls were then never linked. A move typed into the Transform panel takes the hulls along (it went through RecordTransform, which knew nothing of them; past 5 m they were lost for good and a later delete left them in the .col). Renaming the object moves its record. The record is written by an edit of the import (and of the duplicate), so undo takes it back out of the file. Links are worked out once per operation for the whole selection instead of once per node, each time re-reading the file and walking every object and placement. Source. object_import takes `occurrence`: the n-th thing of that name in the source (actors first, then frames - the ones that draw before helpers of the same name), and reports how many there are. Position and heading must be finite. The source archive is kept between imports from it (let go after a minute, re-read when its working copy changed) instead of being read whole for every object. "N hull(s) of its own" is now worded as what it is - the hulls standing inside its box - in the result and in the tool description. Prop thumbnails. The archive is read on a pool thread (Stage) and only drawn on the UI thread (Draw); the tick is guarded, since nothing above a DispatcherTimer catches; the kept picture is keyed by the working copy's time stamp as well and replaces the older ones; the held scene is let go when the tab has all its pictures; texture folders no longer accumulate across archives (TextureLibrary.ClearFolders). --probe-object-transplant 53/53 (the take-back on a scratch copy; an OM-named texture through carry, sweep, park and return). --probe-object-import-live (new; a real window with `hill` loaded, the district's working copy compared with its state before and put back): 30/30 - typed move and turn with undo/redo, drag, rename and its undo, delete and its undo, everything undone leaves no record, occurrence past the end, a source whose collision is cut short refused twice with the same answer, a door the pack refuses leaving manifest and folder as they were. --probe-prop-catalog 9/9, --probe-editor-tools 23/23. Not checked: the Props tab filling in a window (only Stage/Draw are), a companion that fails to copy, hulls linked through the box on an object whose pivot really is far off (the probe's object has it close). Not changed: "its own hulls" are still chosen by position - an archive has nothing else to go by; item descriptions and the prefab entry of an UNDONE import still stay in the working copy. --- src/Illusion.Assets/Frames/FrameTransplant.cs | 7 + .../Properties/FramePropertyCatalog.cs | 23 +- src/Illusion.Assets/Sds/ArchiveCarry.cs | 111 +++++- src/Illusion.Assets/Sds/ImportLinks.cs | 44 ++- src/Illusion.Formats/Archive/SdsManifest.cs | 6 +- src/Illusion.Mcp/EditorModels.cs | 5 +- src/Illusion.Mcp/IEditorSession.cs | 2 +- src/Illusion.Mcp/Tools/EditorTools.cs | 9 +- .../Textures/TextureLibrary.cs | 16 + src/Illusion/Diagnostics/ProbeRunner.cs | 7 + .../Probes/ObjectImportLiveProbes.cs | 326 ++++++++++++++++++ .../Probes/ObjectTransplantProbes.cs | 47 ++- .../Diagnostics/Probes/PropCatalogProbes.cs | 10 +- src/Illusion/Mcp/AppEditorSession.cs | 6 +- src/Illusion/Viewport/D3DImageHost.cs | 185 +++++++--- src/Illusion/Viewport/ImportLinkEdit.cs | 19 + .../Viewport/ObjectImportController.cs | 128 ++++++- .../Viewport/PropThumbnailRenderer.cs | 185 ++++++++-- .../Viewport/TransformEditController.cs | 47 ++- .../ScenePanel/Tabs/PropsTabView.xaml.cs | 32 +- 20 files changed, 1076 insertions(+), 139 deletions(-) create mode 100644 src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs create mode 100644 src/Illusion/Viewport/ImportLinkEdit.cs diff --git a/src/Illusion.Assets/Frames/FrameTransplant.cs b/src/Illusion.Assets/Frames/FrameTransplant.cs index 8a58cb1..08bd1c7 100644 --- a/src/Illusion.Assets/Frames/FrameTransplant.cs +++ b/src/Illusion.Assets/Frames/FrameTransplant.cs @@ -97,6 +97,13 @@ public IReadOnlyCollection MaterialHashes } } + /// The textures the copied meshes name themselves, not through a material: the occlusion + /// map (OMTextureHash). A copy keeps the name, so the file has to be in its new archive too. + public IReadOnlyCollection DirectTextures => + Frames.OfType().Select(m => m.OMTextureHash) + .Where(om => om is { Hash: not 0, String.Length: > 0 }).Select(om => om.String) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + /// The item descriptions the copied collision frames name, by hash. public IReadOnlyCollection CollisionHashes => Frames.OfType().Select(c => c.Hash).Where(h => h != 0).ToHashSet(); diff --git a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs index a5f63c8..ae72390 100644 --- a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs +++ b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs @@ -23,7 +23,7 @@ public static IReadOnlyList Build(FrameNodeAdapter node) FrameObjectBase o = node.Frame; var c = new GroupCollector(); - AddBase(o, c); + AddBase(o, node, c); // if-chain, not a switch — the vendor hierarchy makes many types share a base (SingleMesh:Joint, // Model:SingleMesh, Area:Joint …); each applicable layer contributes its own groups. @@ -44,11 +44,11 @@ public static IReadOnlyList Build(FrameNodeAdapter node) } // ── Common (every frame object) ── - private static void AddBase(FrameObjectBase o, GroupCollector c) + private static void AddBase(FrameObjectBase o, FrameNodeAdapter node, GroupCollector c) { // Name is always editable: the name-table rewrite on save (SdsWriter.SaveFrameNameTable) keeps an // on-table object's listed name in sync. The type + triangle count live in the tab's header card. - c.AddCommon("Identity", HashNameDesc("Base.Name", "Name", () => o.Name, name => o.Name.Set(name), + c.AddCommon("Identity", HashNameDesc("Base.Name", "Name", () => o.Name, name => Rename(o, node, name), "The FNV64 hash is re-derived from the name.")); // Frame table — the flag bits (1..4096) and whether the object is listed in the frame table at all. @@ -64,6 +64,23 @@ private static void AddBase(FrameObjectBase o, GroupCollector c) // The local transform is likewise absent — the gizmo and the Position/Rotation/Scale fields own it. } + // The collision an imported object was given is tied to it by its NAME (Sds.ImportLinks) — the only handle + // a static object has. A rename takes the record along; an undo of the rename comes through here as well. + private static void Rename(FrameObjectBase o, FrameNodeAdapter node, string name) + { + string old = o.Name.String; + o.Name.Set(name); + try + { + Sds.ImportLinks.Rename(MafiaEnvironment.ExtractedDir(node.Document.SourceArchive), old, name); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException + or InvalidOperationException or NullReferenceException) + { + // no game environment (a probe, a loose file): there is no link file to keep in step + } + } + private static void AddJoint(FrameObjectJoint j, GroupCollector c) { FrameObjectJoint.NodeStruct[]? data = j.Data; diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index 80c9c54..0167eae 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -50,8 +50,16 @@ public sealed record Report( /// Carries the three kinds from one extracted folder to another. /// The actor's definition name, or null/empty for an object no actor places. public static Report Carry(string fromDir, string toDir, IReadOnlyCollection materialHashes, - IReadOnlyCollection collisionHashes, string? definition) + IReadOnlyCollection collisionHashes, string? definition) => + Carry(fromDir, toDir, materialHashes, collisionHashes, definition, directTextures: []); + + /// The same, with the textures the object's meshes name themselves rather than through a + /// material — the occlusion map a mesh carries by name (OMTextureHash). The copy keeps the name, + /// so the file has to come along like any other. + public static Report Carry(string fromDir, string toDir, IReadOnlyCollection materialHashes, + IReadOnlyCollection collisionHashes, string? definition, IReadOnlyCollection directTextures) { + ArgumentNullException.ThrowIfNull(directTextures); ArgumentException.ThrowIfNullOrEmpty(fromDir); ArgumentException.ThrowIfNullOrEmpty(toDir); ArgumentNullException.ThrowIfNull(materialHashes); @@ -62,7 +70,7 @@ public static Report Carry(string fromDir, string toDir, IReadOnlyCollection(); var elsewhere = new List(); - CarryTextures(from, to, fromDir, toDir, materialHashes, textures, elsewhere); + CarryTextures(from, to, fromDir, toDir, materialHashes, directTextures, textures, elsewhere); var descriptions = new List(); var unresolved = new List(); @@ -73,10 +81,12 @@ public static Report Carry(string fromDir, string toDir, IReadOnlyCollection materialHashes, List added, List elsewhere) + IReadOnlyCollection materialHashes, IReadOnlyCollection directTextures, List added, + List elsewhere) { MafiaMaterials.EnsureLoaded(); - var wanted = new HashSet(StringComparer.OrdinalIgnoreCase); + var wanted = new HashSet(directTextures.Where(t => !string.IsNullOrWhiteSpace(t)), + StringComparer.OrdinalIgnoreCase); foreach (ulong hash in materialHashes) { IMaterial? material = MafiaMaterials.Collection?.FindByHash(hash); @@ -94,16 +104,84 @@ private static void CarryTextures(SdsManifest from, SdsManifest to, string fromD elsewhere.Add(texture); continue; } - if (!CopyEntry(from, to, fromDir, toDir, texture)) continue; + // A texture stored split keeps its top level in a companion entry, and its own entry says so — + // one without the other is a chain the game streams and does not find. So the companion goes + // FIRST, and a texture whose companion cannot be brought is not brought either. + string companion = SdsImportTypes.MipNameFor(texture); + bool withCompanion = from.HasFile(companion) && !to.HasFile(companion); + if (withCompanion && !CopyEntry(from, to, fromDir, toDir, companion)) + { + elsewhere.Add(texture); + continue; + } + if (!CopyEntry(from, to, fromDir, toDir, texture)) + { + if (withCompanion && to.RemoveEntry(companion)) File.Delete(Path.Combine(toDir, companion)); + elsewhere.Add(texture); + continue; + } added.Add(texture); Remember(toDir, texture); TextureSearchIndex.Register(Path.Combine(toDir, texture)); + } + } - // A texture stored split keeps its top level in a companion entry, and its own entry says so — - // one without the other is a chain the game streams and does not find. - string companion = SdsImportTypes.MipNameFor(texture); - if (from.HasFile(companion) && !to.HasFile(companion)) CopyEntry(from, to, fromDir, toDir, companion); + /// What a working copy's lists held before a carry, so that a carry whose import then fails can + /// be taken back whole (). + public sealed class Before + { + internal Before(string dir, byte[] manifest, HashSet files, Dictionary prefabs, byte[]? register) + { + Dir = dir; + Manifest = manifest; + Files = files; + Prefabs = prefabs; + Register = register; } + + internal string Dir { get; } + internal byte[] Manifest { get; } + internal HashSet Files { get; } + internal Dictionary Prefabs { get; } + internal byte[]? Register { get; } + } + + /// Notes what 's manifest, prefab containers and carried register hold now. + public static Before Note(string toDir) + { + ArgumentException.ThrowIfNullOrEmpty(toDir); + SdsManifest manifest = SdsManifest.Load(toDir); + string register = Path.Combine(toDir, RegisterName); + return new Before( + toDir, + File.ReadAllBytes(Path.Combine(toDir, "SDSContent.xml")), + new HashSet(manifest.Entries.Select(e => e.File), StringComparer.OrdinalIgnoreCase), + manifest.GetFiles("PREFAB").ToDictionary(p => p, File.ReadAllBytes, StringComparer.OrdinalIgnoreCase), + File.Exists(register) ? File.ReadAllBytes(register) : null); + } + + /// + /// Takes a carry back out of the working copy: the files it added are removed and the manifest, the prefab + /// containers and the carried register are what they were at . For an import that was + /// refused or failed after its carry — the object never arrived, and what was brought for it would + /// otherwise stay in the working copy, and in every archive built from it, for nothing. + /// Only for the moment right after the carry: it puts the lists back as a whole. It goes by what the + /// manifest gained since the note, not by the carry's report — a carry that threw half-way has no report. + /// + public static void TakeBack(Before before) + { + ArgumentNullException.ThrowIfNull(before); + string dir = before.Dir; + List gained = [.. SdsManifest.Load(dir).Entries.Select(e => e.File).Where(f => !before.Files.Contains(f))]; + + AtomicFile.WriteAllBytes(Path.Combine(dir, "SDSContent.xml"), before.Manifest); + foreach ((string path, byte[] bytes) in before.Prefabs) AtomicFile.WriteAllBytes(path, bytes); + string register = Path.Combine(dir, RegisterName); + if (before.Register is { } kept) AtomicFile.WriteAllBytes(register, kept); + else File.Delete(register); + + // Files the manifest did not list before, and so nothing of the archive's own. + foreach (string file in gained) File.Delete(Path.Combine(dir, file.TrimStart('/', '\\'))); } private static void CarryItemDescriptions(SdsManifest from, SdsManifest to, string fromDir, string toDir, @@ -178,13 +256,15 @@ private static bool CarryPrefab(SdsManifest from, SdsManifest to, string fromDir if (fields is not { Count: >= 3 } || !int.TryParse(fields[^1].Value, out int version)) return false; var fresh = new PrefabFile(); if (!fresh.Adopt(source, hash)) return false; - const string name = "PREFAB_carried.prf"; - AtomicFile.WriteAllBytes(Path.Combine(toDir, name), fresh.ToBytes()); - return to.AddEntry(fields[0].Value, name, version, [.. fields.Skip(2).Take(fields.Count - 3)]); + AtomicFile.WriteAllBytes(Path.Combine(toDir, CarriedPrefabName), fresh.ToBytes()); + return to.AddEntry(fields[0].Value, CarriedPrefabName, version, [.. fields.Skip(2).Take(fields.Count - 3)]); } return false; } + // The container a destination with no prefab of its own is given. + private const string CarriedPrefabName = "PREFAB_carried.prf"; + // Beside the working copy, never in its manifest: packing goes by the manifest, so the game never sees it. private const string RegisterName = "illusion_carried.json"; @@ -371,6 +451,13 @@ public static IReadOnlyList SweepUnused(string extracted, Formats.Frames } } + // And the ones a mesh names itself — its occlusion map — which no material leads to. + foreach (Formats.Frames.ObjectTypes.FrameObjectSingleMesh mesh in + scene.FrameObjects.Values.OfType()) + { + if (mesh.OMTextureHash is { Hash: not 0, String.Length: > 0 } direct) named.Add(direct.String); + } + SdsManifest manifest = SdsManifest.Load(extracted); var dropped = new List(); lock (ParkedHere) diff --git a/src/Illusion.Assets/Sds/ImportLinks.cs b/src/Illusion.Assets/Sds/ImportLinks.cs index 3a0e146..f70384b 100644 --- a/src/Illusion.Assets/Sds/ImportLinks.cs +++ b/src/Illusion.Assets/Sds/ImportLinks.cs @@ -28,16 +28,41 @@ public static void Set(string extractedDir, string frameName, IEnumerable List list = [.. hulls.Distinct()]; if (list.Count == 0) links.Remove(frameName); else links[frameName] = list; + Write(extractedDir, links); + } + + /// Moves an object's record to its new name. An object is found by its name here, so one that is + /// renamed without this leaves its hulls behind the next time it is moved or deleted. + public static void Rename(string extractedDir, string oldName, string newName) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + if (string.IsNullOrEmpty(oldName) || string.IsNullOrEmpty(newName) || oldName == newName) return; + Dictionary> links = Read(extractedDir); + if (!links.Remove(oldName, out List? hulls)) return; + links[newName] = hulls; + Write(extractedDir, links); + } + + private static void Write(string extractedDir, Dictionary> links) + { + string path = Path.Combine(extractedDir, FileName); try { - AtomicFile.WriteAllBytes(Path.Combine(extractedDir, FileName), JsonSerializer.SerializeToUtf8Bytes(links)); + if (links.Count == 0) File.Delete(path); + else AtomicFile.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(links)); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { // an unrecorded link costs moving the hull by hand, nothing more } + lock (Cache) Cache.Remove(path); } + // The file as last read, by path, with the time stamp it had: every drag start, delete and duplicate asks + // about every node it touches, and parsing the file for each of them is what made a large selection stall. + private static readonly Dictionary> Links)> Cache = + new(StringComparer.OrdinalIgnoreCase); + /// The hulls recorded for an object of this archive, or none. public static IReadOnlyList HullsOf(string extractedDir, string frameName) { @@ -57,13 +82,24 @@ private static Dictionary> Read(string extractedDir) string path = Path.Combine(extractedDir, FileName); try { - return File.Exists(path) - ? JsonSerializer.Deserialize>>(File.ReadAllText(path)) ?? new() - : new(); + if (!File.Exists(path)) return new(); + DateTime stamp = File.GetLastWriteTimeUtc(path); + lock (Cache) + { + if (Cache.TryGetValue(path, out var kept) && kept.Stamp == stamp) return Copy(kept.Links); + } + Dictionary> links = + JsonSerializer.Deserialize>>(File.ReadAllText(path)) ?? new(); + lock (Cache) Cache[path] = (stamp, links); + return Copy(links); } catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) { return new(); } } + + // Callers change what they are handed (Set, Rename); the kept one stays as the file is. + private static Dictionary> Copy(Dictionary> links) => + links.ToDictionary(pair => pair.Key, pair => pair.Value.ToList()); } diff --git a/src/Illusion.Formats/Archive/SdsManifest.cs b/src/Illusion.Formats/Archive/SdsManifest.cs index b866801..327a1c6 100644 --- a/src/Illusion.Formats/Archive/SdsManifest.cs +++ b/src/Illusion.Formats/Archive/SdsManifest.cs @@ -161,7 +161,11 @@ public bool RemoveEntry(string fileName) } if (dropped.Count == 0) return false; foreach (System.Xml.XmlNode entry in dropped) entry.ParentNode?.RemoveChild(entry); - document.Save(path); + // Through a temp file, as AddEntry does: this now runs on every save of a scene with carried textures, + // and a manifest cut short by a crash is an archive that can be neither packed nor re-extracted. + string temp = path + ".tmp"; + document.Save(temp); + File.Move(temp, path, overwrite: true); _entries.RemoveAll(e => string.Equals(e.File, fileName, StringComparison.OrdinalIgnoreCase)); return true; diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index 6eb58f4..046e66f 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -63,9 +63,12 @@ public sealed record SeasonMirrorOutcome( /// copied, and what was carried into the working copy beside the scene — textures, item descriptions, a /// prefab entry. are textures neither archive carries: they live in an /// archive the game loads beside the source, and may not be loaded where the object now stands. +/// is how many things in the source answer to the name asked for, and +/// which of them this was. public sealed record ObjectImportOutcome( string Kind, string Name, int Frames, int Meshes, IReadOnlyList Textures, int ItemDescriptions, - bool Prefab, IReadOnlyList TexturesElsewhere, int UnresolvedCollisions, string Collision); + bool Prefab, IReadOnlyList TexturesElsewhere, int UnresolvedCollisions, string Collision, + int NamedSo = 1, int Occurrence = 1); /// Where the viewport camera is. Yaw and pitch are in radians, as the camera keeps them. public sealed record CameraInfo(float[] Position, float Yaw, float Pitch, float OrbitDistance); diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index 1e96e8a..4cdeee6 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -102,7 +102,7 @@ public interface IEditorSession /// the rotation the original has. /// For scenery: auto (its own hulls, else its convex hull), convex, box, mesh or none. string? ImportObject(string sourceArchive, string name, string newName, float[] position, float? yawDegrees, - string? collision, out ObjectImportOutcome? outcome); + string? collision, int occurrence, out ObjectImportOutcome? outcome); /// Duplicates the selection (undoable) and leaves the copies selected. Null on success. string? DuplicateSelected(out IReadOnlyList copies); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index dafa9bc..d92a1ab 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -523,7 +523,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; the files carried into the working copy stay there, unused, if it is undone. Skinned models cannot travel yet.")] + [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.")] public static async Task ImportObject( IEditorSession editor, IUiThreadMarshal ui, @@ -532,14 +532,15 @@ public static async Task ImportObject( [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("Collision for scenery: 'auto' (default — its own hulls from the source, else its convex hull), '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("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) { try { - if (position.Length != 3) return ToolResult.Invalid("position takes three numbers"); + 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, out outcome)); + () => editor.ImportObject(sourceArchive, name, newName, position, yawDegrees, collision, occurrence, out outcome)); if (refused != null) return ToolResult.Invalid(refused); return ToolResult.Json(new { success = true, imported = outcome, status = await ui.RunAsync(editor.Status) }); } diff --git a/src/Illusion.Rendering/Textures/TextureLibrary.cs b/src/Illusion.Rendering/Textures/TextureLibrary.cs index 17f1e3c..76a09e5 100644 --- a/src/Illusion.Rendering/Textures/TextureLibrary.cs +++ b/src/Illusion.Rendering/Textures/TextureLibrary.cs @@ -84,6 +84,22 @@ public void AddFolder(string folder) } } + /// + /// Forgets every registered folder — for a renderer that shows one thing after another, each with + /// folders of its own: a texture is taken from the FIRST folder that has its name, so folders left from + /// what was shown before would lend their textures to what is shown next. Textures already acquired stay + /// as they are until their leases are returned. + /// + public void ClearFolders() + { + lock (_sync) + { + if (_folders.Count == 0) return; + _folders.Clear(); + _foldersVersion++; + } + } + /// /// Forgets a cached texture whose FILE was rewritten, so the next reads it again. /// Meshes that already hold the old view keep drawing with it until they rebind or are disposed — the diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 3425d00..6a7809b 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -178,6 +178,13 @@ public static bool TryRun(string[] args) case "--probe-geometry-sweep": GeometrySweepProbes.RunGeometrySweepProbe(); return true; + // The import as the editor makes it, in a real window with a district loaded: undo, the tree, the + // link to collision. Touches the install and puts it back. Output: %TEMP%\illusion_object_import_live.txt + case "--probe-object-import-live": + ObjectImportLiveProbes.Run( + args.Length >= 2 ? args[1] : "hill", + args.Length >= 3 ? args[2] : @"shops\harry.sds"); + return true; case "--probe-object-transplant": ObjectTransplantProbes.RunObjectTransplantProbe( args.Length >= 2 ? args[1] : "hill", diff --git a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs new file mode 100644 index 0000000..a966509 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs @@ -0,0 +1,326 @@ +using System.IO; +using System.Numerics; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Adapters; +using Illusion.Assets.Sds; +using Illusion.Domain; +using Illusion.Mcp; +using Illusion.Scene; +using Illusion.Viewport; +using Illusion.Views; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Object import the way the editor does it: a real window with a district loaded, and the import, the moves, +/// the rename, the delete and their undo driven through the same calls the panels and the tools make. What +/// the scratch-copy probe () cannot reach — the undo stack, the tree, the +/// link between an imported object and the collision it was given. +/// +/// Unlike the other probes this one touches the install: it puts a copy of the district's own archive beside +/// the game's archives as the source to import from (so that nothing has to be carried into the district), +/// and one step imports a door from an interior to see a refused import take its carry back. Nothing is saved +/// or built; the source copy is removed, and the district's working copy is compared with — and, if anything +/// differs, put back to — what it held before. +/// +/// +internal static class ObjectImportLiveProbes +{ + // Output: %TEMP%\illusion_object_import_live.txt + internal static void Run(string area, string interior) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_object_import_live.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)}"); + } + + MainWindow? window = null; + FileInfo? probeSds = null; + string? probeDir = null, dir = null; + Dictionary? keptBytes = null; + HashSet? keptNames = null; + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + var district = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "city", area + ".sds")); + var interiorSds = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", interior)); + if (!district.Exists) { sb.AppendLine("INIT FAIL: no such district " + district.FullName); return; } + if (System.Diagnostics.Process.GetProcessesByName("mafia2").Length > 0) + { + sb.AppendLine("INIT FAIL: the game is running"); + return; + } + + // The safety net: every small file of the district's working copy as it is now, and its file list. + dir = SdsMeshLoader.EnsureExtracted(district); + keptNames = new HashSet(Directory.GetFiles(dir).Select(Path.GetFileName)!, StringComparer.OrdinalIgnoreCase); + keptBytes = Directory.GetFiles(dir) + .Where(f => Path.GetExtension(f).ToLowerInvariant() is ".xml" or ".json" or ".prf" or ".ids") + .ToDictionary(f => Path.GetFileName(f), File.ReadAllBytes, StringComparer.OrdinalIgnoreCase); + + probeSds = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "illusion_probe_src.sds")); + probeDir = MafiaEnvironment.ExtractedDir(probeSds); + if (Directory.Exists(probeDir)) Directory.Delete(probeDir, recursive: true); + File.Copy(district.FullName, probeSds.FullName, overwrite: true); + + window = new MainWindow + { + WindowState = System.Windows.WindowState.Normal, WindowStartupLocation = System.Windows.WindowStartupLocation.Manual, + Left = -4000, Top = 0, Width = 1280, Height = 800, ShowInTaskbar = false, ShowActivated = false, + }; + window.Show(); + D3DImageHost host = window.Viewport; + var session = new AppEditorSession(); + Pump(() => host.Areas.Count > 0, 60); + string? refused = session.LoadArea(area, winter: false, discardUnsavedEdits: true); + Pump(() => session.Status() is { Loading: false, Meshes: > 0 }, 240); + Check($"the district loads in a window of this process", refused == null && session.Status() is { Loading: false, Meshes: > 0 }, + refused ?? $"{session.Status().Meshes} meshes"); + if (session.Status() is not { Loading: false, Meshes: > 0 }) return; + session.SetView(null, collision: true, null, null, null); + Pump(() => Nodes(host).Any(n => n.Source is CollisionInstanceAdapter), 60); + int hullsAtStart = Nodes(host).Count(n => n.Source is CollisionInstanceAdapter); + Check("its collision layer is in the scene", hullsAtStart > 0, $"{hullsAtStart} placements"); + + // A small mesh of the district's own scene to import back in from the copy. + SceneNode? pick = Nodes(host).FirstOrDefault(n => n.Source is FrameNodeAdapter + { + Frame: Formats.Frames.ObjectTypes.FrameObjectSingleMesh { Name.String.Length: > 0 } mesh + } && mesh.GetType() == typeof(Formats.Frames.ObjectTypes.FrameObjectSingleMesh) + && mesh.Refs.ContainsKey(Formats.Frames.FrameEntryRefTypes.Geometry) + && (mesh.Boundings.Max - mesh.Boundings.Min).Length() is > 0.5f and < 12f); + Check("the scene has a small mesh to import a copy of", pick != null, pick?.Name ?? ""); + if (pick == null) return; + string source = ((FrameNodeAdapter)pick.Source!).Frame.Name.String; + Vector3 home = ((IFrameNode)pick.Source!).WorldTransform.Translation; + float[] at = [home.X, home.Y, home.Z + 60f]; // well clear of everything that stands there + + // ── 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); + Check("a heading that is not finite is refused", + session.ImportObject(probeSds.FullName, source, "probe_live_nan", at, float.NaN, null, 1, 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); + 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; + SceneNode? node = Nodes(host).FirstOrDefault(n => n.Source is FrameNodeAdapter f && f.Frame.Name.String == "probe_live"); + Check("it has a row in the tree", node != null); + if (node == null) return; + Check("the hull it was given is written down", ImportLinks.HullsOf(dir, "probe_live").Count == 1); + IReadOnlyList hulls = host.LinkedCollisionNodes(node); + Check("and found again as its own", hulls.Count == 1, $"{hulls.Count} linked placement(s)"); + if (hulls.Count != 1) return; + var hull = (IFrameNode)hulls[0].Source!; + var moved = (IFrameNode)node.Source!; + Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) + ? Vector3.Transform(hull.WorldTransform.Translation, inv) : new Vector3(float.NaN); + Vector3 relative = Relative(); + + // ── a move typed into the panel: thirty metres, past the distance the link is looked for in ── + int undoBefore = host.History.UndoCount; + Vector3 hullAt = hull.WorldTransform.Translation, objectAt = moved.WorldTransform.Translation; + Matrix4x4 before = moved.LocalTransform, after = before; + after.Translation += new Vector3(30f, 0f, 0f); + moved.LocalTransform = after; + host.RecordTransform(node, before, after); + Check("a typed move takes the hull along", + ProbeAssert.Approx(hull.WorldTransform.Translation - hullAt, moved.WorldTransform.Translation - objectAt, 0.01f) + && (moved.WorldTransform.Translation - objectAt).Length() > 29f, + $"object moved {(moved.WorldTransform.Translation - objectAt).Length():0.##} m, hull {(hull.WorldTransform.Translation - hullAt).Length():0.##} m"); + Check("as one step of undo", host.History.UndoCount == undoBefore + 1, $"{host.History.UndoCount - undoBefore} entr(ies)"); + Check("and the hull is still found as the object's afterwards", host.LinkedCollisionNodes(node).Count == 1); + host.Undo(); + Check("undo puts both back", ProbeAssert.Approx(hull.WorldTransform.Translation, hullAt, 0.01f) + && ProbeAssert.Approx(moved.WorldTransform.Translation, objectAt, 0.01f)); + host.Redo(); + Check("redo moves both again", ProbeAssert.Approx(hull.WorldTransform.Translation - hullAt, new Vector3(30f, 0f, 0f), 0.01f)); + + // ── a typed turn: the hull keeps its place relative to the object ── + before = moved.LocalTransform; + after = Matrix4x4.CreateRotationZ(MathF.PI / 2f) * before; + moved.LocalTransform = after; + host.RecordTransform(node, before, after); + Check("a typed turn carries the hull round with the object", ProbeAssert.Approx(Relative(), relative, 0.02f), + $"offset in the object's space {relative} → {Relative()}"); + + // ── a drag, as the move tool makes it ── + Vector3 dragFrom = hull.WorldTransform.Translation; + host.Selection.SetSelection([node], node); + host.GizmoBeginDrag(Rendering.Gizmos.GizmoMode.Move); + host.GizmoApplyWorldDelta(Matrix4x4.CreateTranslation(0f, 12f, 0f)); + host.GizmoEndDrag(); + Check("a drag still takes the hull along", + ProbeAssert.Approx(hull.WorldTransform.Translation - dragFrom, new Vector3(0f, 12f, 0f), 0.01f)); + + // ── a rename takes the record along ── + string? renamed = session.SetProperty("probe_live", "Base.Name", "probe_live_renamed"); + Check("renaming the object moves its record to the new name", + renamed == null && ImportLinks.HullsOf(dir, "probe_live").Count == 0 && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 1, + renamed ?? ""); + Check("and the hull is found under the new name", host.LinkedCollisionNodes(node).Count == 1); + host.Undo(); + Check("undoing the rename moves the record back", + ImportLinks.HullsOf(dir, "probe_live").Count == 1 && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 0); + host.Redo(); + + // ── delete takes the hull; undo brings both back ── + host.Selection.SetSelection([node], node); + host.DeleteSelected(); + Check("deleting the object deletes its hull", + Nodes(host).Count(n => n.Source is CollisionInstanceAdapter) == hullsAtStart + && !Nodes(host).Any(n => ReferenceEquals(n, node))); + host.Undo(); + Check("and undo brings both back", + Nodes(host).Count(n => n.Source is CollisionInstanceAdapter) == hullsAtStart + 1 && host.LinkedCollisionNodes(node).Count == 1); + + // ── everything undone: no object, no hull, no record ── + for (int guard = 0; host.History.CanUndo && guard < 50; guard++) host.Undo(); + Check("with everything undone the scene has neither the object nor its hull", + !Nodes(host).Any(n => n.Source is FrameNodeAdapter f && f.Frame.Name.String.StartsWith("probe_live", StringComparison.Ordinal)) + && Nodes(host).Count(n => n.Source is CollisionInstanceAdapter) == hullsAtStart); + Check("and the link file has no record of it", + 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 _); + 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 ── + foreach (string file in Formats.Archive.SdsManifest.Load(probeDir).GetFiles("Collisions")) + { + byte[] whole = File.ReadAllBytes(file); + 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 _); + 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", + second != null && second == first, second ?? "imported"); + Check("the scene has as many objects as before, and nothing more to undo", + Frames(host, district) == objectsBefore && !host.History.CanUndo, $"{objectsBefore} → {Frames(host, district)}"); + + // ── a refused import takes its carry back out of the working copy ── + if (interiorSds.Exists) + { + string interiorDir = SdsMeshLoader.EnsureExtracted(interiorSds); + Formats.Frames.ExtractedSds theirs = Formats.Frames.ExtractedSds.Load(interiorDir); + Assets.Actors.ActorPlacements theirPlacements = Assets.Actors.ActorPlacements.Load(theirs.Manifest, theirs.FrameResource!); + Formats.Actors.ActorEntry? door = theirPlacements.All.FirstOrDefault( + a => a.Type == Formats.Actors.EntityType.Door && theirPlacements.TargetOf(a) != null); + // A name that is free among the scene's objects and taken in the pack: the copy is made, the + // carry is done, and only then does the pack refuse the actor. + string? taken = Nodes(host).Select(n => n.Source).OfType().Select(a => a.Name) + .FirstOrDefault(n => n.Length > 0 && !Nodes(host).Any(x => x.Source is FrameNodeAdapter f && f.Frame.Name.String == n)); + if (door != null && taken != null) + { + 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 _); + if (why == null) + { + sb.AppendLine($" (the pack took an actor named '{taken}' a second time — nothing to check; undone)"); + host.Undo(); + } + else + { + string[] left = [.. Directory.GetFiles(dir).Select(Path.GetFileName).Where(n => !namesBefore.Contains(n!))!]; + Check("a door refused by the pack leaves the working copy's manifest as it was", + File.ReadAllBytes(Path.Combine(dir, "SDSContent.xml")).AsSpan().SequenceEqual(manifestBefore), why); + Check("and none of the files brought for it", left.Length == 0, string.Join(", ", left.Take(6))); + } + } + else + { + sb.AppendLine(" (no door in the interior, or no actor name free among the frames — the refused-carry step was skipped)"); + } + } + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + try { window?.Close(); } + catch (Exception ex) { sb.AppendLine(" (closing the window: " + ex.Message + ")"); } + try + { + if (probeSds is { } sds && File.Exists(sds.FullName)) File.Delete(sds.FullName); + if (probeDir != null && Directory.Exists(probeDir)) Directory.Delete(probeDir, recursive: true); + } + catch (IOException ex) { sb.AppendLine(" (removing the source copy: " + ex.Message + ")"); } + + // The district's working copy against what it held at the start. + if (dir != null && keptBytes != null && keptNames != null) + { + var differs = new List(); + foreach (string file in Directory.GetFiles(dir)) + { + string name = Path.GetFileName(file); + if (!keptNames.Contains(name)) + { + differs.Add("+" + name); + File.Delete(file); + } + else if (keptBytes.TryGetValue(name, out byte[]? bytes) && !File.ReadAllBytes(file).AsSpan().SequenceEqual(bytes)) + { + differs.Add("~" + name); + File.WriteAllBytes(file, bytes); + } + } + foreach (string name in keptBytes.Keys.Where(n => !File.Exists(Path.Combine(dir, n)))) + { + differs.Add("-" + name); + File.WriteAllBytes(Path.Combine(dir, name), keptBytes[name]); + } + string parked = Path.Combine(dir, ArchiveCarry.ParkedFolder); + if (Directory.Exists(parked)) Directory.Delete(parked, recursive: true); + // The book-keeping an import writes beside the scene (shared geometry) is expected to differ; + // anything else is a leftover this probe is here to catch. + string[] unexpected = [.. differs.Where(d => !d.EndsWith("illusion_geometry.json", StringComparison.OrdinalIgnoreCase))]; + Check("the district's working copy is left as it was found", unexpected.Length == 0, + differs.Count == 0 ? "" : "put back: " + string.Join(", ", differs)); + } + sb.Insert(0, $"OBJECT IMPORT LIVE PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static IEnumerable Nodes(D3DImageHost host) + { + var stack = new Stack(host.Roots.Reverse()); + while (stack.Count > 0) + { + SceneNode node = stack.Pop(); + yield return node; + for (int i = node.Children.Count - 1; i >= 0; i--) stack.Push(node.Children[i]); + } + } + + private static int Frames(D3DImageHost host, FileInfo archive) => + Nodes(host).Select(n => n.Source).OfType() + .Where(d => string.Equals(d.SourceArchive.FullName, archive.FullName, StringComparison.OrdinalIgnoreCase)) + .Sum(d => d.Frame.FrameObjects.Count); + + private static void Pump(Func until, int seconds) + { + DateTime end = DateTime.UtcNow.AddSeconds(seconds); + while (!until() && DateTime.UtcNow < end) + { + System.Windows.Threading.Dispatcher.CurrentDispatcher.Invoke( + () => { }, System.Windows.Threading.DispatcherPriority.Background); + Thread.Sleep(15); + } + } +} diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 6758276..1100c92 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -252,8 +252,47 @@ [.. all.SelectMany(t => t.VertexBuffers).Select(b => b.Hash)], && prefab.ToBytes().AsSpan().SequenceEqual(prefabBytes), $"{BitConverter.ToInt32(prefabBytes, 0)} + 4 against {prefabBytes.Length}"); + // ── A carry that is taken back: the working copy's lists and files are what they were ── + string again2 = CopyWithoutTextures(SdsMeshLoader.EnsureExtracted(districtSds), Path.Combine(scratch, "takeback")); + string[] filesBefore = [.. Directory.GetFiles(again2).Select(Path.GetFileName).Order()!]; + byte[] manifestBefore2 = File.ReadAllBytes(Path.Combine(again2, "SDSContent.xml")); + Dictionary prefabsBefore2 = SdsManifest.Load(again2).GetFiles("PREFAB").ToDictionary(p => p, File.ReadAllBytes); + ArchiveCarry.Before note = ArchiveCarry.Note(again2); + ArchiveCarry.Report taken = ArchiveCarry.Carry(sourceDir, again2, carriedDoor.MaterialHashes, + carriedDoor.CollisionHashes, door.LinkedDefinition); + bool broughtSomething = taken.ItemDescriptions.Count > 0 && taken.Prefab + && !File.ReadAllBytes(Path.Combine(again2, "SDSContent.xml")).AsSpan().SequenceEqual(manifestBefore2); + ArchiveCarry.TakeBack(note); + Check("a carry taken back leaves the manifest, the prefab and the folder as they were", + broughtSomething && File.ReadAllBytes(Path.Combine(again2, "SDSContent.xml")).AsSpan().SequenceEqual(manifestBefore2) + && prefabsBefore2.All(p => File.ReadAllBytes(p.Key).AsSpan().SequenceEqual(p.Value)) + && Directory.GetFiles(again2).Select(Path.GetFileName).Order().SequenceEqual(filesBefore), + $"brought {taken.Textures.Count} texture(s), {taken.ItemDescriptions.Count} description(s), a prefab entry: {taken.Prefab}; " + + $"{Directory.GetFiles(again2).Length} files after against {filesBefore.Length} before"); + + // ── A texture a mesh names itself (its occlusion map) is carried like one a material names ── + SdsManifest theirManifest = SdsManifest.Load(sourceDir); + string? direct = theirManifest.Entries.Where(e => e.Type == "Texture").Select(e => e.File) + .FirstOrDefault(f => !SdsManifest.Load(dir).HasFile(f) && File.Exists(Path.Combine(sourceDir, f))); + if (direct != null) + { + ((FrameObjectSingleMesh)carriedScenery.Root).OMTextureHash = new HashName(direct); + Check("the copy says which texture its mesh names itself", + carriedScenery.DirectTextures.Contains(direct, StringComparer.OrdinalIgnoreCase), direct); + } + else + { + sb.AppendLine(" (the source has no texture the district lacks — the occlusion-map step has nothing to carry)"); + } + // ── Carried textures and undo: what a save sweeps is parked, and comes back with its object ── - ArchiveCarry.Report sceneryCarry = ArchiveCarry.Carry(sourceDir, dir, carriedScenery.MaterialHashes, [], null); + ArchiveCarry.Report sceneryCarry = ArchiveCarry.Carry(sourceDir, dir, carriedScenery.MaterialHashes, [], null, + carriedScenery.DirectTextures); + if (direct != null) + { + Check("and the carry brings it", sceneryCarry.Textures.Contains(direct, StringComparer.OrdinalIgnoreCase), + $"{sceneryCarry.Textures.Count} texture(s) for the scenery"); + } string[] brought = [.. doorCarry.Textures.Concat(propCarry.Textures).Concat(sceneryCarry.Textures) .Distinct(StringComparer.OrdinalIgnoreCase)]; bool InUse(string texture) => SdsManifest.Load(dir).HasFile(texture) && File.Exists(Path.Combine(dir, texture)); @@ -315,7 +354,8 @@ void PutBack() TakeOut(); ArchiveCarry.SweepUnused(dir, ours); ArchiveCarry.Report second = ArchiveCarry.Carry(sourceDir, dir, - [.. carriedDoor.MaterialHashes, .. carriedProp.MaterialHashes, .. carriedScenery.MaterialHashes], [], null); + [.. carriedDoor.MaterialHashes, .. carriedProp.MaterialHashes, .. carriedScenery.MaterialHashes], [], null, + carriedScenery.DirectTextures); PutBack(); ArchiveCarry.SweepUnused(dir, ours); Check("a texture carried again while it was parked is the one in use, and the parked copy is dropped", @@ -335,7 +375,8 @@ void PutBack() && !Directory.Exists(Path.Combine(dir, ArchiveCarry.ParkedFolder))); PutBack(); ArchiveCarry.Carry(sourceDir, dir, - [.. carriedDoor.MaterialHashes, .. carriedProp.MaterialHashes, .. carriedScenery.MaterialHashes], [], null); + [.. carriedDoor.MaterialHashes, .. carriedProp.MaterialHashes, .. carriedScenery.MaterialHashes], [], null, + carriedScenery.DirectTextures); } // ── A destination that has neither a prefab nor an item description of its own ── diff --git a/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs b/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs index 5cbb4ec..fd87a05 100644 --- a/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs +++ b/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs @@ -58,12 +58,13 @@ void Check(string name, bool ok, string detail = "") if (Directory.Exists(pictures)) Directory.Delete(pictures, recursive: true); Directory.CreateDirectory(pictures); using var renderer = new PropThumbnailRenderer(); - int drawn = 0, tried = 0; + int drawn = 0, tried = 0, kept = 0; foreach (PropEntry entry in entries.GroupBy(e => e.Category).Select(g => g.First()).Take(9)) { tried++; if (renderer.Render(entry) is not BitmapSource picture) continue; drawn++; + if (PropThumbnailRenderer.Cached(entry) != null) kept++; var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(picture)); using FileStream file = File.Create(Path.Combine(pictures, $"{entry.Category}_{entry.Label}.png")); @@ -71,6 +72,13 @@ void Check(string name, bool ok, string detail = "") if (drawn == 1) Check("a picture is not a blank tile", HasDetail(picture)); } Check("each shelf's first prop gets a picture", drawn == tried, $"{drawn} of {tried}"); + Check("and each is kept, under a key the next look-up finds", kept == drawn, $"{kept} of {drawn}"); + // A prop whose archive is not there: no picture, and no exception — the tile asks from a timer tick. + PropEntry gone = entries[0] with { Archive = @"city\illusion_no_such_archive.sds" }; + bool quiet; + try { quiet = renderer.Draw(renderer.Stage(gone)) == null; } + catch (Exception) { quiet = false; } + Check("a prop whose archive cannot be read gets no picture and throws nothing", quiet); } catch (Exception ex) { diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index eb4d98f..ef5222c 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -532,9 +532,11 @@ public CameraInfo Camera() } public string? ImportObject(string sourceArchive, string name, string newName, float[] position, float? yawDegrees, - string? collision, out ObjectImportOutcome? outcome) + string? collision, int occurrence, 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"; @@ -550,7 +552,7 @@ public CameraInfo Camera() return $"collision '{collision}' is none of auto, convex, box, mesh, none"; } return host.ObjectImporting.Import(destination, sourceArchive, name, newName, - new Vector3(position[0], position[1], position[2]), yawDegrees, out outcome, hulls); + new Vector3(position[0], position[1], position[2]), yawDegrees, out outcome, hulls, occurrence); } public string? DuplicateSelected(out IReadOnlyList copies) diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index 2ebb395..5eeb026 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -280,7 +280,8 @@ public void DeleteSelected() // An object carried in from another archive takes the collision it was given along — those hulls // stand where it stood and are nothing without it. List selected = [.. Selection.Selected]; - List linked = [.. selected.SelectMany(LinkedCollisionNodes).Where(n => !selected.Contains(n)).Distinct()]; + var held = new HashSet(selected); + List linked = [.. LinkedCollision(selected).Values.SelectMany(hulls => hulls).Where(n => !held.Contains(n)).Distinct()]; if (linked.Count > 0) Selection.SetSelection([.. selected, .. linked], SelectedNode); int before = Editing.History.UndoCount; @@ -294,56 +295,155 @@ public void DeleteSelected() /// /// The collision placements a piece of scenery this toolkit carried in was given (): - /// for each recorded hull, the placement of it nearest the object, within a few metres. Empty for everything - /// else — a stock object's collision is not tied to it by anything, and guessing by position would take a - /// building's hull along with a bench. + /// for each recorded hull, the placement of it nearest the object, within a few metres of where the object + /// IS — its box, not its pivot. Empty for everything else — a stock object's collision is not tied to it by + /// anything, and guessing by position would take a building's hull along with a bench. /// - internal IReadOnlyList LinkedCollisionNodes(SceneNode node) + /// The world matrix to look from instead of the node's own — for a caller that + /// has already moved the object and wants the hulls it had where it stood before. + internal IReadOnlyList LinkedCollisionNodes(SceneNode node, Matrix4x4? standingAt = null) => + LinkedCollision([node], standingAt is { } at ? new Dictionary { [node] = at } : null) + .TryGetValue(node, out List? hulls) ? hulls : []; + + /// + /// The same for many nodes at once, each archive's links worked out ONCE. A drag start, a delete and a + /// duplicate ask about every node they touch; answered node by node, each answer read the link file and + /// walked every object and every placement of the archive, and a large selection stalled for seconds. + /// Only nodes that have hulls are in the result. + /// + internal Dictionary> LinkedCollision(IEnumerable nodes, + IReadOnlyDictionary? standingAt = null) { - if (node.Source is not FrameNodeAdapter frame || node.OwningDocumentNode() is not { Source: SceneDocumentAdapter scene } documentNode) - { - return []; - } - string dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); - string name = frame.Frame.Name.String; - if (Assets.Sds.ImportLinks.HullsOf(dir, name).Count == 0 || FindCollisionLayer(documentNode) is not { } layer) return []; - - // Every linked object of the archive bids for the placements of its hulls, nearest first, and a placement - // goes to the first bid only. A tie — a copy on top of its original — goes to the name that sorts first, - // so the answer does not change from one call to the next. - var standing = new Dictionary(StringComparer.Ordinal); - foreach (object value in scene.Frame.FrameObjects.Values) + var result = new Dictionary>(); + var asked = new List<(SceneNode Node, FrameNodeAdapter Frame, SceneDocumentAdapter Scene, SceneNode Document)>(); + foreach (SceneNode node in nodes) { - if (value is Formats.Frames.ObjectTypes.FrameObjectBase f && f.Name.String is { Length: > 0 } n) standing.TryAdd(n, f.WorldTransform.Translation); + if (node.Source is FrameNodeAdapter frame && node.OwningDocumentNode() is { Source: SceneDocumentAdapter scene } documentNode) + { + asked.Add((node, frame, scene, documentNode)); + } } - var bids = new List<(float Distance, string Object, int Slot, int Placement)>(); - List placements = [.. layer.Children]; - foreach ((string linked, List hulls) in Assets.Sds.ImportLinks.All(dir)) + + foreach (var archive in asked.GroupBy(a => a.Scene)) { - if (!standing.TryGetValue(linked, out Vector3 at)) continue; - for (int slot = 0; slot < hulls.Count; slot++) + SceneDocumentAdapter scene = archive.Key; + string dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); + IReadOnlyDictionary> links = Assets.Sds.ImportLinks.All(dir); + if (links.Count == 0 || FindCollisionLayer(archive.First().Document) is not { } layer) continue; + + // The object a recorded name belongs to: the first of that name, as the link was made for. + var owners = new Dictionary(StringComparer.Ordinal); + foreach (object value in scene.Frame.FrameObjects.Values) + { + if (value is Formats.Frames.ObjectTypes.FrameObjectBase f && f.Name.String is { Length: > 0 } n && links.ContainsKey(n)) + { + owners.TryAdd(n, f); + } + } + var looksFrom = new Dictionary(); + foreach (var a in archive) + { + if (standingAt != null && standingAt.TryGetValue(a.Node, out Matrix4x4 at)) looksFrom[a.Frame.Frame] = at; + } + + List placements = [.. layer.Children]; + var byHull = new Dictionary>(); + for (int i = 0; i < placements.Count; i++) + { + if (placements[i].Source is not CollisionInstanceAdapter ci) continue; + if (!byHull.TryGetValue(ci.Instance.Hash, out List? of)) byHull[ci.Instance.Hash] = of = []; + of.Add(i); + } + + // Every linked object of the archive bids for the placements of its hulls, nearest first, and a + // placement goes to the first bid only. A tie — a copy on top of its original — goes to the name + // that sorts first, so the answer does not change from one call to the next. + var bids = new List<(float Distance, string Object, int Slot, int Placement)>(); + foreach ((string linked, List hulls) in links) { - for (int i = 0; i < placements.Count; i++) + if (!owners.TryGetValue(linked, out Formats.Frames.ObjectTypes.FrameObjectBase? owner)) continue; + List<(Matrix4x4 World, Matrix4x4 ToLocal, Vector3 Min, Vector3 Max)> boxes = + BoxesOf(owner, looksFrom.TryGetValue(owner, out Matrix4x4 from) ? from : null, out Vector3 pivot); + for (int slot = 0; slot < hulls.Count; slot++) { - if (placements[i].Source is not CollisionInstanceAdapter ci || ci.Instance.Hash != hulls[slot]) continue; - float distance = Vector3.Distance(ci.Instance.Position, at); - if (distance < 5f) bids.Add((distance, linked, slot, i)); + if (!byHull.TryGetValue(hulls[slot], out List? candidates)) continue; + foreach (int i in candidates) + { + Vector3 at = ((CollisionInstanceAdapter)placements[i].Source!).Instance.Position; + // To the object's box: a district mesh's pivot can be metres from its geometry, and + // measured from there its own hulls were "too far" and never linked. The pivot still + // counts — nothing that was linked before stops being linked. + float distance = Vector3.Distance(pivot, at); + foreach ((Matrix4x4 world, Matrix4x4 toLocal, Vector3 min, Vector3 max) in boxes) + { + Vector3 nearest = Vector3.Transform(Vector3.Clamp(Vector3.Transform(at, toLocal), min, max), world); + distance = MathF.Min(distance, Vector3.Distance(nearest, at)); + } + if (distance < 5f) bids.Add((distance, linked, slot, i)); + } + } + } + + var takenPlacements = new HashSet(); + var filledSlots = new HashSet<(string, int)>(); + var found = new Dictionary>(StringComparer.Ordinal); + foreach ((_, string linked, int slot, int placement) in bids + .OrderBy(b => b.Distance).ThenBy(b => b.Object, StringComparer.Ordinal).ThenBy(b => b.Placement)) + { + if (takenPlacements.Contains(placement) || filledSlots.Contains((linked, slot))) continue; + takenPlacements.Add(placement); + filledSlots.Add((linked, slot)); + if (!found.TryGetValue(linked, out List? list)) found[linked] = list = []; + list.Add(placements[placement]); + } + + foreach (var a in archive) + { + string name = a.Frame.Frame.Name.String; + if (name is { Length: > 0 } && owners.TryGetValue(name, out Formats.Frames.ObjectTypes.FrameObjectBase? owner) + && ReferenceEquals(owner, a.Frame.Frame) && found.TryGetValue(name, out List? hulls)) + { + result[a.Node] = hulls; } } } + return result; + } - var takenPlacements = new HashSet(); - var filledSlots = new HashSet<(string, int)>(); - var found = new List(); - foreach ((_, string linked, int slot, int placement) in bids - .OrderBy(b => b.Distance).ThenBy(b => b.Object, StringComparer.Ordinal).ThenBy(b => b.Placement)) + // Where an object is, for the question "is this placement beside it": the stored box of every mesh under + // it, each with its world matrix and that matrix's inverse. Read from the frames — no geometry is decoded. + // With the whole subtree is taken as standing where that matrix puts the root. + private static List<(Matrix4x4 World, Matrix4x4 ToLocal, Vector3 Min, Vector3 Max)> BoxesOf( + Formats.Frames.ObjectTypes.FrameObjectBase root, Matrix4x4? from, out Vector3 pivot) + { + Matrix4x4 shift = Matrix4x4.Identity; + if (from is { } standing && Matrix4x4.Invert(root.WorldTransform, out Matrix4x4 back)) shift = back * standing; + pivot = (root.WorldTransform * shift).Translation; + + var boxes = new List<(Matrix4x4, Matrix4x4, Vector3, Vector3)>(); + var seen = new HashSet(); + var stack = new Stack(); + stack.Push(root); + while (stack.Count > 0 && seen.Count < 256) { - if (takenPlacements.Contains(placement) || filledSlots.Contains((linked, slot))) continue; - takenPlacements.Add(placement); - filledSlots.Add((linked, slot)); - if (linked == name) found.Add(placements[placement]); + Formats.Frames.ObjectTypes.FrameObjectBase frame = stack.Pop(); + if (!seen.Add(frame)) continue; + foreach (Formats.Frames.ObjectTypes.FrameObjectBase child in frame.Children) stack.Push(child); + if (frame is not Formats.Frames.ObjectTypes.FrameObjectSingleMesh mesh + || !mesh.Refs.ContainsKey(Formats.Frames.FrameEntryRefTypes.Geometry)) + { + continue; + } + Vector3 min = mesh.Boundings.Min, max = mesh.Boundings.Max; + Matrix4x4 world = mesh.WorldTransform * shift; + if (min.X > max.X || min.Y > max.Y || min.Z > max.Z || !float.IsFinite(min.LengthSquared() + max.LengthSquared()) + || !Matrix4x4.Invert(world, out Matrix4x4 toLocal)) + { + continue; + } + boxes.Add((world, toLocal, min, max)); } - return found; + return boxes; } /// Whether the selection has anything duplicable — a static mesh or a collision placement. @@ -372,6 +472,7 @@ public void DuplicateSelected() // selection already held were copied by the collision duplicate and are left alone here. private void DuplicateLinkedCollision(IReadOnlyList selected) { + Dictionary> given = LinkedCollision(Editing.LastDuplicates.Select(d => d.Source)); foreach ((SceneNode source, SceneNode copy) in Editing.LastDuplicates) { if (copy.Source is not FrameNodeAdapter copyFrame @@ -381,7 +482,7 @@ private void DuplicateLinkedCollision(IReadOnlyList selected) continue; } var hashes = new List(); - foreach (SceneNode hull in LinkedCollisionNodes(source)) + foreach (SceneNode hull in given.TryGetValue(source, out List? hulls) ? hulls : []) { if (hull.Source is not CollisionInstanceAdapter original) continue; hashes.Add(original.Instance.Hash); @@ -403,8 +504,12 @@ private void DuplicateLinkedCollision(IReadOnlyList selected) } if (hashes.Count > 0) { - Assets.Sds.ImportLinks.Set(Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive), + // As an edit of its own, squashed into the duplicate: an undone duplicate takes its record + // back out of the link file, where a bare write left a name pointing at hulls for good. + var link = new ImportLinkEdit(Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive), copyFrame.Frame.Name.String, hashes); + link.Redo(); + Editing.History.Push(link); } } } diff --git a/src/Illusion/Viewport/ImportLinkEdit.cs b/src/Illusion/Viewport/ImportLinkEdit.cs new file mode 100644 index 0000000..a462835 --- /dev/null +++ b/src/Illusion/Viewport/ImportLinkEdit.cs @@ -0,0 +1,19 @@ +using Illusion.Assets.Sds; +using Illusion.Domain; + +namespace Illusion.Viewport; + +/// +/// Writes down which collision hulls an object was given () — and, undone, puts back +/// what was recorded under that name before. The record lives in a file beside the working copy, outside the +/// scene, so nothing takes it back unless the edit that made it does: written bare, an import or a duplicate +/// that was undone left a name tied to hulls for good, waiting for the next object to be given that name. +/// +internal sealed class ImportLinkEdit(string extractedDir, string frameName, IReadOnlyList hulls) : IEditAction +{ + private readonly List _before = [.. ImportLinks.HullsOf(extractedDir, frameName)]; + + public void Redo() => ImportLinks.Set(extractedDir, frameName, hulls); + + public void Undo() => ImportLinks.Set(extractedDir, frameName, _before); +} diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index e41b34b..e1bdbf7 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -129,11 +129,21 @@ private static void Apply(IReadOnlyList edits) /// 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. /// 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. public string? Import(FileInfo destination, string sourceArchive, string name, string newName, Vector3 at, - float? yawDegrees, out Mcp.ObjectImportOutcome? outcome, Assets.Collisions.CollisionChoice hulls = default) + float? yawDegrees, out Mcp.ObjectImportOutcome? outcome, Assets.Collisions.CollisionChoice hulls = default, + int occurrence = 1) { outcome = null; if (_host.BridgeEditedCount > 0) return "a Blender edit session is open — end it first"; + // A number too large for a float arrives as Infinity, and a heading of Infinity is a rotation of NaNs — + // which would be written into the object's matrix, or the actor's record, and answered with "success". + if (!float.IsFinite(at.X) || !float.IsFinite(at.Y) || !float.IsFinite(at.Z)) return "the position is not a finite point"; + if (yawDegrees is { } asked && !float.IsFinite(asked)) return "the heading is not a finite number"; + if (occurrence < 1) return "occurrence counts from 1"; // The receiving archive's two rows: its scene, and — when it has a pack — its actors. SceneNode? frameRow = AllNodes().FirstOrDefault(n => n.Source is Assets.Adapters.SceneDocumentAdapter d && string.Equals(d.SourceArchive.FullName, destination.FullName, StringComparison.OrdinalIgnoreCase)); @@ -159,19 +169,50 @@ private static void Apply(IReadOnlyList edits) // with no row in the tree to delete them by and no undo entry to take them back. Assets.Frames.FrameTransplant.TransplantedObject? carried = null; bool registered = false; + // What the working copy's lists held before this import brought anything: a refused or failed import + // puts them back, so that an object that never arrived leaves no textures, item descriptions or + // prefab entry behind in the working copy — and in every archive built from it. + Assets.Sds.ArchiveCarry.Before? noted = null; + void TakeCarryBack() + { + if (noted == null) return; + try + { + Assets.Sds.ArchiveCarry.TakeBack(noted); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // what could not be removed stays unused in the working copy; the import is still refused + } + noted = null; + } + try { string sourceDir = Assets.Sds.SdsMeshLoader.EnsureExtracted(sds); - Formats.Frames.ExtractedSds source = Formats.Frames.ExtractedSds.Load(sourceDir); - if (source.FrameResource is not { } theirs) return $"{sds.Name} carries no scene"; - Assets.Actors.ActorPlacements theirPlacements = Assets.Actors.ActorPlacements.Load(source.Manifest, theirs); + (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; - Formats.Actors.ActorEntry? actor = theirPlacements.All.FirstOrDefault( - a => string.Equals(a.EntityName, name, StringComparison.OrdinalIgnoreCase)); + // 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 = + [ + .. theirPlacements.All.Where(a => string.Equals(a.EntityName, name, StringComparison.OrdinalIgnoreCase)), + .. theirs.FrameObjects.Values.OfType() + .Where(o => string.Equals(o.Name.String, name, StringComparison.OrdinalIgnoreCase)) + .OrderBy(o => o is FrameObjectSingleMesh mesh && mesh.Refs.ContainsKey(Formats.Frames.FrameEntryRefTypes.Geometry) + && theirPlacements.ActorCovering(mesh) == null ? 0 : 1), + ]; + if (named.Count == 0) return $"{sds.Name} has neither an actor nor a frame object named '{name}'"; + if (occurrence > named.Count) + { + return $"{sds.Name} has {named.Count} thing(s) named '{name}' — there is no occurrence {occurrence}"; + } + Formats.Actors.ActorEntry? actor = named[occurrence - 1] as Formats.Actors.ActorEntry; Assets.Sds.ArchiveCarry.Report carry; string kind; string? reason; @@ -186,9 +227,13 @@ private static void Apply(IReadOnlyList edits) Assets.MafiaEnvironment.ExtractedDir(destination), Path.GetRelativePath(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds"), sds.FullName)); - Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedObject copied) => - Assets.Sds.ArchiveCarry.Carry(sourceDir, Assets.MafiaEnvironment.ExtractedDir(destination), - copied.MaterialHashes, copied.CollisionHashes, actor?.LinkedDefinition); + Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedObject copied) + { + string into = Assets.MafiaEnvironment.ExtractedDir(destination); + noted = Assets.Sds.ArchiveCarry.Note(into); + return Assets.Sds.ArchiveCarry.Carry(sourceDir, into, copied.MaterialHashes, copied.CollisionHashes, + actor?.LinkedDefinition, copied.DirectTextures); + } if (actor != null) { if (theirPlacements.TargetOf(actor) is not { } prototype) @@ -205,6 +250,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO if (ImportPlaced(actorsRow, frameRow, theirPack, actor, newName, at, facing, carried, out reason) is not { Source: ActorNodeAdapter placed }) { + TakeCarryBack(); return reason ?? "the pack refused the actor"; } registered = true; @@ -221,10 +267,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO } else { - Formats.Frames.ObjectTypes.FrameObjectBase? frame = theirs.FrameObjects.Values - .OfType() - .FirstOrDefault(o => string.Equals(o.Name.String, name, StringComparison.OrdinalIgnoreCase)); - if (frame == null) return $"{sds.Name} has neither an actor nor a frame object named '{name}'"; + 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 @@ -263,9 +306,10 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO outcome = new ObjectImportOutcome(kind, newName, carried.Pairs.Count, carried.Renderables.Count, carry.Textures, carry.ItemDescriptions.Count, carry.Prefab, carry.Elsewhere, carry.Unresolved.Count, - collision); + collision, named.Count, occurrence); _host.RaiseNotice( - $"Imported '{name}' from {sds.Name} as '{newName}' ({kind}): {outcome.Frames} frame(s), " + (named.Count > 1 ? $"'{name}' names {named.Count} things in {sds.Name}; this is number {occurrence}. " : "") + + $"Imported '{name}' from {sds.Name} as '{newName}' ({kind}): {outcome.Frames} frame(s), " + $"{outcome.Meshes} mesh(es), {carry.Textures.Count} texture(s), {carry.ItemDescriptions.Count} item " + $"description(s){(carry.Prefab ? ", a prefab entry" : "")} carried" + (carry.Elsewhere.Count > 0 ? $"; {carry.Elsewhere.Count} texture(s) are in neither archive" : "") @@ -283,10 +327,48 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO return $"'{newName}' was imported and is in the scene (undo takes it out), but finishing up failed: {ex.Message}"; } if (carried is { IsAttached: true }) carried.Detach(); + TakeCarryBack(); return "the import failed and nothing of it was left in the scene: " + ex.Message; } } + // The source archive as last read. An import reads the whole of it — every buffer pool, the scene, the + // actor packs — and ten benches out of one interior used to mean ten such reads, on the UI thread. Kept + // while imports from the same archive keep coming and let go a minute after the last one; read again when + // its working copy has been written to since. + private string? _sourceDir; + private DateTime _sourceStamp; + private Formats.Frames.ExtractedSds? _source; + private Assets.Actors.ActorPlacements? _sourcePlacements; + private System.Windows.Threading.DispatcherTimer? _sourceRelease; + + private (Formats.Frames.ExtractedSds Source, Assets.Actors.ActorPlacements? Placements) SourceOf(string dir) + { + DateTime stamp = Directory.GetLastWriteTimeUtc(dir); + if (_source == null || !string.Equals(_sourceDir, dir, StringComparison.OrdinalIgnoreCase) || _sourceStamp != stamp) + { + _source = null; + Formats.Frames.ExtractedSds fresh = Formats.Frames.ExtractedSds.Load(dir); + _sourcePlacements = fresh.FrameResource is { } scene ? Assets.Actors.ActorPlacements.Load(fresh.Manifest, scene) : null; + _source = fresh; + _sourceDir = dir; + _sourceStamp = stamp; + } + if (_sourceRelease == null) + { + _sourceRelease = new System.Windows.Threading.DispatcherTimer { Interval = TimeSpan.FromSeconds(60) }; + _sourceRelease.Tick += (_, _) => + { + _sourceRelease.Stop(); + _source = null; + _sourcePlacements = null; + }; + } + _sourceRelease.Stop(); + _sourceRelease.Start(); + return (_source, _sourcePlacements); + } + /// /// The collision a piece of scenery brings: the source's hulls that stand inside its footprint, re-placed /// by the same move that took the object to its new spot — or one cooked here, as its convex hull, its box @@ -355,13 +437,21 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO string name = hulls.Count == 1 ? $"{carried.Root.Name} collision" : $"{carried.Root.Name} collision {i + 1}"; edits.AddRange(_host.CollisionEditing.BuildCreateHull(document, layer, hulls[i].Added, hulls[i].Placement, name) ?? []); } - // Written down, so the hulls move and are deleted with the object (see D3DImageHost.LinkedCollisionNodes). - Assets.Sds.ImportLinks.Set(Assets.MafiaEnvironment.ExtractedDir(destination), carried.Root.Name.String, - hulls.Select(h => h.Placement.Hash)); + // Written down, so the hulls move and are deleted with the object (see D3DImageHost.LinkedCollision) — + // as one more edit of the import, so that an import undone takes its record back out of the file. + if (hulls.Count > 0) + { + edits.Add(new ImportLinkEdit(Assets.MafiaEnvironment.ExtractedDir(destination), carried.Root.Name.String, + [.. hulls.Select(h => h.Placement.Hash)])); + } + // "Its own" is a guess by position: nothing in an archive ties a static object to its collision, so + // what is taken is every hull standing inside the object's box. For a shelf that includes what stood + // on it — hence the wording, and the way out. summary = hulls.Count == 0 ? "none — " + (refusal ?? "nothing to make one of") : hulls[0].FromSource - ? $"{hulls.Count} hull(s) of its own from the source archive" + ? $"{hulls.Count} hull(s) that stood inside its box in the source archive, taken as its own " + + "(collision=convex, box or mesh makes one from its shape instead)" : choice switch { Assets.Collisions.CollisionChoice.Box => "its box, cooked here", diff --git a/src/Illusion/Viewport/PropThumbnailRenderer.cs b/src/Illusion/Viewport/PropThumbnailRenderer.cs index 94678d2..ebe9d1d 100644 --- a/src/Illusion/Viewport/PropThumbnailRenderer.cs +++ b/src/Illusion/Viewport/PropThumbnailRenderer.cs @@ -6,6 +6,7 @@ using Illusion.Assets.Frames; using Illusion.Assets.Library; using Illusion.Assets.Sds; +using Illusion.Domain; using Illusion.Formats.Frames; using Illusion.Formats.Frames.ObjectTypes; using Illusion.Formats.Hashing; @@ -21,7 +22,13 @@ namespace Illusion.Viewport; /// Built like the material thumbnails — a headless GPU stack of its own, created on first use, drawing into a /// small offscreen target that is read back — because the viewport's renderer holds the scene. A picture costs /// reading its archive's scene, so it is drawn once and kept on disk; the archive last read is kept in memory, -/// since the library asks for the props of one archive one after another. UI thread only. +/// since the library asks for the props of one archive one after another. +/// +/// +/// In two halves: reads the archive and converts the object — any thread, nothing of the +/// GPU — and puts it on the offscreen target, UI thread only. Reading a district's scene +/// takes a second or more, and done in the tile timer's tick it froze the editor once per archive. Neither +/// half throws: the tick has nothing above it to catch an exception. /// /// internal sealed class PropThumbnailRenderer : IDisposable @@ -34,7 +41,9 @@ internal sealed class PropThumbnailRenderer : IDisposable private SharedRenderTarget? _target; private bool _failed; + private readonly object _read = new(); private string? _archive; // the archive whose scene is held below + private string? _archiveDir; private ExtractedSds? _scene; private ActorPlacements? _placements; @@ -44,46 +53,69 @@ internal sealed class PropThumbnailRenderer : IDisposable /// The kept picture, without drawing anything — null when there is none yet. public static ImageSource? Cached(PropEntry entry) { - string path = PathOf(entry); + string path = StemOf(entry) + ".png"; return File.Exists(path) ? Load(path) : null; } /// The picture for one prop: the kept one, or a fresh one drawn and kept. Null when the object /// cannot be found or drawn — the tile then keeps its placeholder. - public ImageSource? Render(PropEntry entry) + public ImageSource? Render(PropEntry entry) => Cached(entry) ?? Draw(Stage(entry)); + + /// What worked out for one prop: what to draw, where its textures are and + /// what it has to fit in. Nothing to draw when is empty. + public sealed class Staged { - string path = PathOf(entry); - if (File.Exists(path)) return Load(path); - if (!EnsureContext()) return null; + public required PropEntry Entry { get; init; } + public List Meshes { get; init; } = []; + public string TextureFolder { get; init; } = ""; + public Vector3 Centre { get; init; } + public float Radius { get; init; } + } + /// + /// Reads the prop's archive (the last one read is kept, since a library asks for one archive's props one + /// after another) and converts the object for drawing. Any thread; touches nothing of the GPU; never + /// throws — a prop that cannot be found or read comes back with nothing to draw. + /// + public Staged Stage(PropEntry entry) + { try { - if (_archive != entry.Archive) + ExtractedSds? held; + ActorPlacements? placements; + string dir; + lock (_read) { - var sds = new FileInfo(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", entry.Archive)); - string dir = Assets.MafiaEnvironment.ExtractedDir(sds); - _scene = ExtractedSds.Load(dir); - _placements = _scene.FrameResource is { } fr ? ActorPlacements.Load(_scene.Manifest, fr) : null; - _archive = entry.Archive; - _renderer!.Textures.AddFolder(dir); + if (_archive != entry.Archive || _scene == null) + { + var sds = new FileInfo(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", entry.Archive)); + _archiveDir = Assets.MafiaEnvironment.ExtractedDir(sds); + _scene = null; + ExtractedSds fresh = ExtractedSds.Load(_archiveDir); + _placements = fresh.FrameResource is { } fr ? ActorPlacements.Load(fresh.Manifest, fr) : null; + _scene = fresh; + _archive = entry.Archive; + } + held = _scene; + placements = _placements; + dir = _archiveDir!; } - if (_scene?.FrameResource is not { } scene || _placements == null) return null; + if (held?.FrameResource is not { } scene || placements == null) return new Staged { Entry = entry }; FrameObjectBase? root = entry.Kind == "Scenery" ? scene.FrameObjects.Values.OfType().FirstOrDefault(o => o.Name.String == entry.Name) - : _placements.All.FirstOrDefault(a => a.EntityName == entry.Name) is { } actor ? _placements.TargetOf(actor) : null; - if (root == null) return null; + : placements.All.FirstOrDefault(a => a.EntityName == entry.Name) is { } actor ? placements.TargetOf(actor) : null; + if (root == null) return new Staged { Entry = entry }; Assets.MafiaMaterials.EnsureLoaded(); - _renderer!.Clear(); - Vector3 min = new(float.MaxValue), max = new(float.MinValue); + var meshes = new List(); foreach (FrameObjectSingleMesh mesh in Subtree(root).OfType()) { if (!mesh.Refs.ContainsKey(FrameEntryRefTypes.Geometry)) continue; // The prototype's own world — an actor's object stands at the origin; nothing here places it. - if (SdsMeshLoader.TryConvert(mesh, placement: Matrix4x4.Identity) is not { } data) continue; - _renderer.AddMesh(data); + if (SdsMeshLoader.TryConvert(mesh, placement: Matrix4x4.Identity) is { } data) meshes.Add(data); } + Vector3 min = new(float.MaxValue), max = new(float.MinValue); foreach ((Vector3[] positions, _) in FrameTransplant.TrianglesOf(root)) { foreach (Vector3 p in positions) @@ -92,31 +124,69 @@ internal sealed class PropThumbnailRenderer : IDisposable max = Vector3.Max(max, p); } } - if (min.X > max.X) return null; + if (min.X > max.X || meshes.Count == 0 || !float.IsFinite((max - min).Length())) return new Staged { Entry = entry }; + + return new Staged + { + Entry = entry, Meshes = meshes, TextureFolder = dir, Centre = (min + max) / 2f, + Radius = MathF.Max(0.05f, (max - min).Length() / 2f), + }; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Whatever a broken working copy throws on the way in — the list is not knowable in advance, and + // the answer to each is the same: this tile keeps its placeholder. + return new Staged { Entry = entry }; + } + } + + /// Draws what worked out and keeps the picture. UI thread. Null when there is + /// nothing to draw or the GPU would not have it; never throws. + public ImageSource? Draw(Staged staged) + { + ArgumentNullException.ThrowIfNull(staged); + if (staged.Meshes.Count == 0 || !EnsureContext()) return null; + try + { + // Only this archive's folder: a texture is taken from the first folder that has its name, and + // folders left from the archives drawn before would lend theirs. + _renderer!.Clear(); + _renderer.Textures.ClearFolders(); + _renderer.Textures.AddFolder(staged.TextureFolder); + foreach (MeshData mesh in staged.Meshes) _renderer.AddMesh(mesh); // Three-quarters from the front and above, far enough for the bounding sphere to fill the frame. - Vector3 centre = (min + max) / 2f; - float radius = MathF.Max(0.05f, (max - min).Length() / 2f); - float distance = radius / MathF.Sin(_renderer.Camera.Fov / 2f) * 1.05f; + float distance = staged.Radius / MathF.Sin(_renderer.Camera.Fov / 2f) * 1.05f; Vector3 from = Vector3.Normalize(new Vector3(0.75f, -1f, 0.65f)); - _renderer.Camera.Near = MathF.Max(0.01f, distance - radius * 1.5f); - _renderer.Camera.Far = distance + radius * 3f; - _renderer.Camera.LookAt(centre + from * distance, centre); + _renderer.Camera.Near = MathF.Max(0.01f, distance - staged.Radius * 1.5f); + _renderer.Camera.Far = distance + staged.Radius * 3f; + _renderer.Camera.LookAt(staged.Centre + from * distance, staged.Centre); _renderer.Render(_target!); byte[] bgra = RenderTargetReadback.Read(_gpu!, _target!); BitmapSource bmp = BitmapSource.Create(Width, Height, 96, 96, PixelFormats.Bgra32, null, bgra, Width * 4); bmp.Freeze(); - Save(bmp, path); + Save(bmp, staged.Entry); return bmp; } - catch (Exception ex) when (ex is IOException or InvalidDataException or InvalidOperationException - or Formats.SdsFormatException or ArgumentException) + catch (Exception ex) when (ex is not OutOfMemoryException) { return null; } } + /// Lets go of the archive held for the next prop — for when there is no next prop: a district's + /// whole scene is not worth keeping in memory for a library tab that has all its pictures. + public void Rest() + { + lock (_read) + { + _scene = null; + _placements = null; + _archive = null; + } + } + private static IEnumerable Subtree(FrameObjectBase root) { var seen = new HashSet(); @@ -131,7 +201,49 @@ private static IEnumerable Subtree(FrameObjectBase root) } } - private static string PathOf(PropEntry entry) => Path.Combine(CacheDir, $"{Fnv64.Hash(entry.Key):x16}.png"); + // Bumped when what a tile shows changes, so that pictures kept by an older build are not handed out. + private const int Look = 2; + + // "_". The picture is drawn from the working + // copy, and a district's working copy is edited — keyed by the prop alone, a picture stayed whatever + // became of the object. One prop's pictures share the first half, which is how the outdated ones are found. + private static string StemOf(PropEntry entry) + { + long copy = 0; + try + { + var sds = new FileInfo(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", entry.Archive)); + string dir = Assets.MafiaEnvironment.ExtractedDir(sds); + if (Directory.Exists(dir)) copy = Directory.GetLastWriteTimeUtc(dir).Ticks; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException + or InvalidOperationException or NullReferenceException) + { + // no environment: then there is no working copy to speak of + } + return Path.Combine(CacheDir, $"{Fnv64.Hash(entry.Key):x16}_{Fnv64.Hash($"{copy}|{Look}"):x16}"); + } + + private static bool _legacyDropped; + + private static void DropOlder(string stem) + { + string whose = Path.GetFileName(stem)[..17]; // sixteen digits and the underscore + foreach (string other in Directory.GetFiles(CacheDir, whose + "*")) + { + if (!Path.GetFileNameWithoutExtension(other).Equals(Path.GetFileName(stem), StringComparison.OrdinalIgnoreCase)) + { + File.Delete(other); + } + } + if (_legacyDropped) return; + _legacyDropped = true; + // Pictures under the name the first version of this gave them (no underscore): nothing reads them. + foreach (string old in Directory.GetFiles(CacheDir, "*.png")) + { + if (!Path.GetFileName(old).Contains('_')) File.Delete(old); + } + } private static ImageSource? Load(string path) { @@ -151,15 +263,16 @@ private static IEnumerable Subtree(FrameObjectBase root) } } - private static void Save(BitmapSource bmp, string path) + private static void Save(BitmapSource bmp, PropEntry entry) { try { Directory.CreateDirectory(CacheDir); + string stem = StemOf(entry); var encoder = new PngBitmapEncoder(); encoder.Frames.Add(BitmapFrame.Create(bmp)); - using FileStream file = File.Create(path); - encoder.Save(file); + using (FileStream file = File.Create(stem + ".png")) encoder.Save(file); + DropOlder(stem); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -200,8 +313,6 @@ public void Dispose() _renderer = null; _target = null; _gpu = null; - _scene = null; - _placements = null; - _archive = null; + Rest(); } } diff --git a/src/Illusion/Viewport/TransformEditController.cs b/src/Illusion/Viewport/TransformEditController.cs index 9cb9adb..ee29710 100644 --- a/src/Illusion/Viewport/TransformEditController.cs +++ b/src/Illusion/Viewport/TransformEditController.cs @@ -76,14 +76,12 @@ public void GizmoBeginDrag(GizmoMode mode) // An object carried in from another archive drags the collision it was given along with it: nothing in // the file ties the two, so without this the hull would stay where the object used to stand. - foreach ((SceneNode node, _, _) in _dragGroup.ToList()) + var dragged = new HashSet(_dragGroup.Select(g => g.Node)); + foreach (List hulls in _host.LinkedCollision(dragged.ToList()).Values) { - foreach (SceneNode hull in _host.LinkedCollisionNodes(node)) + foreach (SceneNode hull in hulls) { - if (hull.Source is IFrameNode h && _dragGroup.All(g => !ReferenceEquals(g.Node, hull))) - { - _dragGroup.Add((hull, h.WorldTransform, h.LocalTransform)); - } + if (hull.Source is IFrameNode h && dragged.Add(hull)) _dragGroup.Add((hull, h.WorldTransform, h.LocalTransform)); } } } @@ -163,8 +161,41 @@ public void GizmoEndDrag() public void RecordTransform(SceneNode node, Matrix4x4 before, Matrix4x4 after) { if (before == after) return; - History.Push(new TransformEdit(this, new[] { (node, before, after) })); - if (Persists(node, before, after)) _host.Persistence.MarkFrameModified(node); + var items = new List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> { (node, before, after) }; + IReadOnlyList mints = []; + + // A number typed into the Transform panel comes through here, not through a drag — and the collision + // an imported object was given has to follow it all the same. It used to stay where the object had + // stood; past a few metres it was then no longer found as the object's, and a later delete left it in + // the file as an obstacle nobody can see. The hulls are looked for from where the object WAS, and + // given the same world-space change, the way a drag gives it to its group. + if (node.Source is IFrameNode moved and not CollisionInstanceAdapter) + { + Matrix4x4 parent = moved.ParentWorldTransform; + Matrix4x4 worldBefore = before * parent; + if (Matrix4x4.Invert(worldBefore, out Matrix4x4 back)) + { + Matrix4x4 delta = back * (after * parent); + var hulls = new List<(SceneNode Node, Matrix4x4 Before)>(); + foreach (SceneNode hull in _host.LinkedCollisionNodes(node, worldBefore)) + { + if (hull.Source is not IFrameNode h) continue; + hulls.Add((hull, h.LocalTransform)); + h.LocalTransform = TransformOps.WorldDeltaToLocal(h.WorldTransform, h.ParentWorldTransform, delta); + SyncNodeMeshes(hull); + } + if (hulls.Count > 0) + { + // A placement cannot store a scale: a resize becomes a rescaled hull, as at the end of a drag. + mints = _host.CollisionEditing.MintPreviewedScales(hulls.Select(x => x.Node).ToList()); + foreach ((SceneNode hull, Matrix4x4 hullBefore) in hulls) + { + items.Add((hull, hullBefore, ((IFrameNode)hull.Source!).LocalTransform)); + } + } + } + } + RecordGroupTransform(items, mints); } // Records a group's local-transform changes as ONE undoable edit (keeping only the objects that moved), diff --git a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs index df9f8d3..e526305 100644 --- a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs +++ b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs @@ -29,6 +29,9 @@ public partial class PropsTabView : UserControl private readonly List _all = []; private readonly PropThumbnailRenderer _thumbnails = new(); private readonly DispatcherTimer _pictures; + // The one prop whose archive is being read, on a pool thread, and the tile it is for. + private Task? _reading; + private PropTileViewModel? _readingFor; private bool _loading; private bool _loaded; private Point _pressedAt; @@ -43,7 +46,19 @@ public PropsTabView() CategoryBox.SelectedIndex = 0; _pictures = new DispatcherTimer(DispatcherPriority.Background) { Interval = TimeSpan.FromMilliseconds(15) }; - _pictures.Tick += (_, _) => DrawNextPicture(); + _pictures.Tick += (_, _) => + { + try + { + DrawNextPicture(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Nothing above a timer tick catches: an exception here ends the program, with whatever is + // unsaved in the editor. The pictures stop instead. + _pictures.Stop(); + } + }; IsVisibleChanged += (_, _) => { if (IsVisible) LoadCatalog(force: false); @@ -110,17 +125,27 @@ private void ApplyFilter() } // One picture per tick: a kept one if any tile shown still lacks it, otherwise one drawn — taking the - // archive the renderer already holds first, so archives are read once rather than once per tile. + // archive the renderer already holds first, so archives are read once rather than once per tile. The + // reading happens on a pool thread; the tick only starts it and, once it is done, draws. private void DrawNextPicture() { + if (_reading is { IsCompleted: true } read && _readingFor is { } readFor) + { + _reading = null; + _readingFor = null; + readFor.Thumbnail = read.IsCompletedSuccessfully ? _thumbnails.Draw(read.Result) : null; + return; + } if (Tiles.ItemsSource is not List shown || !IsVisible) { _pictures.Stop(); return; } + if (_reading != null) return; List missing = shown.Where(t => t.Thumbnail == null && !t.ThumbnailTried).ToList(); if (missing.Count == 0) { + _thumbnails.Rest(); _pictures.Stop(); return; } @@ -137,7 +162,8 @@ private void DrawNextPicture() PropTileViewModel next = missing.OrderBy(t => t.Entry.Archive, StringComparer.OrdinalIgnoreCase).First(); next.ThumbnailTried = true; - next.Thumbnail = _thumbnails.Render(next.Entry); + _readingFor = next; + _reading = Task.Run(() => _thumbnails.Stage(next.Entry)); } private void Rescan_Click(object sender, RoutedEventArgs e) => LoadCatalog(force: true); From 64b4e9b4ef3969e66e26f7a4222d5bf7363502ae Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:20:15 +0300 Subject: [PATCH 051/102] fix(cars): the lesser findings of the self-review - target, notices, tuning values, builds that drop entries The second half of the review of this branch, by finding: Notices and Blender on the resource editor. Its notices went to its own banner only, while editor_notices and the Blender tools read the application's log: blender_push answered "no push arrived" for a push that had landed, and the line car_tuning_set posts was lost. The window now writes the log as the map editor does. blender_open counted the MAP's Blender objects whatever the target and ran out its timeout on a session opened in the resource editor; it asks the target. Target. editor_open_area, object_import, actor_import and editor_mirror_winter make the map the target, as resource_open makes the resource editor: left where it was, the find/select/delete/save/build after an editor_open_area went on acting on the car on the stage. car_tuning_set makes the resource editor the target - its undo entry and build list are that editor's. EditorStatus says which (Target). Open editors. car_clone and car_substitute are made from the working copy on disk: a source open in an editor with unsaved edits is refused. A substitution whose target is loaded in an editor is refused - its working copy is replaced under that editor. resource_open of the archive already on the stage loads nothing (a reload emptied the undo history), and the two cases the window answers with a dialog - an archive another editor has loaded, a save that fails - are answered before it is asked: a tool call that waits for a click never returns. Re-keying. No root frame of the model's name, no prefab entry, entity data filed under another name, no buffers: each was a note while the archive was packed and the car registered. They are failures; the clone or the substitution is taken back. Tuning values. A float that is not finite and an integer wider than its field are refused where the value is written (TuningEditing.Set), so the Tuning tab is covered too. Dropped entries. PackSds removes manifest entries whose file is missing and said nothing. PackResult.Dropped carries them: both editors post them as an error notice (never silenced), editor_build and the clone list them, and archive_build - the tool for hand edits - refuses such a build unless dropMissing is passed, since the entry is gone from the manifest for good. MissingEntries asks without packing. Said, not changed: a clone is registered in the main game's tables only - the three story DLCs ship an ingame.sds of their own, and traffic is added to CARM* tables only; the result's notes name both. The long file work of clone/substitute/build still runs on the UI thread (it keeps the editors from touching the files meanwhile); what was cut is the scratch extraction MemoryFor repeated on every build of an archive that has no shipped figures. --probe-car-clone 75/75 (a car not keyed by the name given is a failure; a working copy short of a file says which). --probe-car-clone-rollback 11/11, --probe-editor-tools 23/23, --probe-object-import-live 30/30. Live, on a probe copy of jeep.sds removed afterwards: NaN, Infinity, 1e40 and an integer past its field refused; the tuning notice in the log; the same car opened twice keeps undo; the target after editor_open_area and after car_tuning_set; resource_open of the district the map has loaded answered at once; clone and substitute refused against the open car; archive_build refused, then built with dropMissing. Not checked: blender_open/blender_push with the resource editor as the target (no Blender in the run), the Build dialogs' new line in a window. --- .../Cars/CarCloner.Substituting.cs | 5 + src/Illusion.Assets/Cars/CarCloner.cs | 44 ++++++-- .../EntityData/TuningEditing.cs | 51 ++++++++- src/Illusion.Assets/Sds/OpenArchives.cs | 9 ++ src/Illusion.Assets/Sds/SdsWriter.cs | 71 ++++++++++--- src/Illusion.Mcp/EditorModels.cs | 14 ++- src/Illusion.Mcp/IEditorSession.cs | 2 +- src/Illusion.Mcp/Tools/EditorTools.cs | 17 ++- .../Diagnostics/Probes/CarCloneProbes.cs | 35 ++++++ src/Illusion/Mcp/AppEditorSession.cs | 100 ++++++++++++++++-- src/Illusion/Views/MainWindow.xaml.cs | 8 ++ .../Views/ResourceEditorWindow.xaml.cs | 24 ++++- 12 files changed, 334 insertions(+), 46 deletions(-) diff --git a/src/Illusion.Assets/Cars/CarCloner.Substituting.cs b/src/Illusion.Assets/Cars/CarCloner.Substituting.cs index 540b8a0..4d4508c 100644 --- a/src/Illusion.Assets/Cars/CarCloner.Substituting.cs +++ b/src/Illusion.Assets/Cars/CarCloner.Substituting.cs @@ -96,6 +96,11 @@ public static partial class CarCloner SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: true, when); journal.Replaced(made.Archive, made.Backup); packed.Add((made.Archive, made.Backup)); + if (made.Dropped.Count > 0) + { + notes.Add($"{to.Name}: {made.Dropped.Count} manifest entr(ies) named a file missing from the working copy " + + $"and were left out — {string.Join(", ", made.Dropped.Take(8))}"); + } } } catch (Exception ex) when (ex is not OutOfMemoryException) diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs index 720f23c..d70afcd 100644 --- a/src/Illusion.Assets/Cars/CarCloner.cs +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -157,6 +157,7 @@ [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.Ex CarCloneResult? result; var packed = new List<(string Archive, string? Backup)>(); + var droppedByArchive = new List<(string Archive, IReadOnlyList Dropped)>(); try { result = CloneExtracted(folders, stem, name, traffic, title, out refusal); @@ -168,6 +169,7 @@ [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.Ex journal.WillCreateFile(to.FullName); SdsWriter.PackResult made = SdsWriter.PackSds(to, createBackup: false, when); packed.Add((made.Archive, made.Backup)); + if (made.Dropped.Count > 0) droppedByArchive.Add((made.Archive, made.Dropped)); } IEnumerable changed = result.TextId == null ? [tablesSds, ingameSds] : [tablesSds, ingameSds, .. textSds]; foreach (FileInfo table in changed) @@ -175,6 +177,7 @@ [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.Ex SdsWriter.PackResult made = SdsWriter.PackSds(table, createBackup: true, when); journal.Replaced(made.Archive, made.Backup); packed.Add((made.Archive, made.Backup)); + if (made.Dropped.Count > 0) droppedByArchive.Add((made.Archive, made.Dropped)); } } catch (Exception ex) when (ex is not OutOfMemoryException) @@ -184,6 +187,28 @@ [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.Ex } // The game finds archives through its cached file list, and a new one is not in it. var notes = new List(result.Notes); + foreach ((string archive, IReadOnlyList dropped) in droppedByArchive) + { + notes.Add($"{Path.GetFileName(archive)}: {dropped.Count} manifest entr(ies) named a file missing from the working " + + $"copy and were left out — {string.Join(", ", dropped.Take(8))}"); + } + // What "registered" does not cover. Each story DLC ships an ingame.sds of its own — paint rows, cover + // points, traffic — and only the main game's is edited here. + string dlcs = Path.Combine(MafiaEnvironment.PcFolder, "dlcs"); + if (Directory.Exists(dlcs)) + { + List own = [.. Directory.GetDirectories(dlcs) + .Where(d => File.Exists(Path.Combine(d, "sds", "tables", "ingame.sds"))).Select(d => Path.GetFileName(d) ?? d)]; + if (own.Count > 0) + { + notes.Add($"not registered in the tables of {string.Join(", ", own)}: each ships its own ingame.sds, and there " + + "the clone has no paint row, no cover points and no traffic"); + } + } + if (traffic) + { + notes.Add("traffic: added to the CARM* tables only — SEASONS_CARS_* and CAR_DEMO_* are left as they are"); + } notes.Add(GameFileIndex.Reset() ? "the game's file list (vfs.bin) was reset — the next start rebuilds it with the new archives" : $"the game's file list was not reset — remove {GameFileIndex.Path} before starting the game, or it will not find the new archives"); @@ -401,8 +426,12 @@ private static List RenameInside(string folder, string model, string nam { FrameObjectBase? root = frame.FrameObjects.Values.OfType() .FirstOrDefault(f => string.Equals(f.Name.String, model, StringComparison.OrdinalIgnoreCase)); + // Each of the four keys below is how the game finds a part of the car under its model name. A + // source that is not keyed the expected way used to become a NOTE while the archive was packed + // and the car registered all the same — "success" for a car the game cannot put together. It is + // a failure now, and the caller takes back what was written. int buffers = GiveOwnBuffers(manifest, frame, model, name); - if (buffers == 0) notes.Add($"{label}: no buffer was renamed — the clone draws from the source car's buffers"); + if (buffers == 0) throw new InvalidDataException($"{label} has no geometry buffers to give the new name to"); if (root != null) { @@ -416,7 +445,7 @@ private static List RenameInside(string folder, string model, string nam } else { - notes.Add($"{label}: no frame is named {model} — the root keeps its name"); + throw new InvalidDataException($"{label} has no frame named {model} — its root cannot be given the new name"); } AtomicFile.WriteAllBytes(frames[0], frame.WriteToStream()); @@ -434,8 +463,11 @@ private static List RenameInside(string folder, string model, string nam foreach (string path in manifest.GetFiles("PREFAB")) { PrefabFile prefab = PrefabFile.Load(path); - if (prefab.Rekey(Fnv64.Hash(model), Fnv64.Hash(name))) AtomicFile.WriteAllBytes(path, prefab.ToBytes()); - else notes.Add($"{label}: the prefab has no entry for {model}"); + if (!prefab.Rekey(Fnv64.Hash(model), Fnv64.Hash(name))) + { + throw new InvalidDataException($"{label}: its prefab has no entry for {model} to file under the new name"); + } + AtomicFile.WriteAllBytes(path, prefab.ToBytes()); } foreach (string path in manifest.GetFiles("EntityDataStorage")) @@ -443,8 +475,8 @@ private static List RenameInside(string folder, string model, string nam EntityDataStorageFile storage = EntityDataStorageFile.Load(path); if (storage.Hash != Fnv64.Hash(model.ToLowerInvariant())) { - notes.Add($"{label}: the entity data is not filed under {model.ToLowerInvariant()}"); - continue; + throw new InvalidDataException( + $"{label}: its entity data is not filed under {model.ToLowerInvariant()}, so it cannot be filed under the new name"); } storage.Hash = Fnv64.Hash(name.ToLowerInvariant()); AtomicFile.WriteAllBytes(path, storage.ToBytes()); diff --git a/src/Illusion.Assets/EntityData/TuningEditing.cs b/src/Illusion.Assets/EntityData/TuningEditing.cs index a92ab72..ffbf490 100644 --- a/src/Illusion.Assets/EntityData/TuningEditing.cs +++ b/src/Illusion.Assets/EntityData/TuningEditing.cs @@ -38,14 +38,27 @@ public sealed record Change( /// /// Writes one field of one table. Returns null — writing nothing — when the storage cannot be opened, - /// the table is not there, or nothing in it sits at that offset. + /// the table is not there, nothing in it sits at that offset, or the value is not one the field can + /// hold (a number that is not finite, an integer wider than the field). /// - public static Change? Set(string storagePath, int tableIndex, uint offset, TuningValue value, string label) + public static Change? Set(string storagePath, int tableIndex, uint offset, TuningValue value, string label) => + Set(storagePath, tableIndex, offset, value, label, out _); + + /// + /// Why nothing was written, when nothing was. + public static Change? Set(string storagePath, int tableIndex, uint offset, TuningValue value, string label, + out string? refusal) { ArgumentException.ThrowIfNullOrEmpty(storagePath); + refusal = null; - if (Open(storagePath) is not { } storage) return null; - if (Field(storage, tableIndex, offset) is not { } field) return null; + if (Open(storagePath) is not { } storage || Field(storage, tableIndex, offset) is not { } field) + { + refusal = $"{label} could not be found in the entity data"; + return null; + } + refusal = WhyNot(field.Kind, value); + if (refusal != null) return null; TuningValue before = Read(field); Apply(field, value); @@ -53,7 +66,7 @@ public sealed record Change( return new Change(storagePath, tableIndex, offset, before, value, label); } - /// Puts a field back to a value it held — the undo of ; the redo is the same + /// Puts a field back to a value it held — the undo of ; the redo is the same /// call with the change's After. public static bool Restore(Change change, TuningValue value) { @@ -83,6 +96,34 @@ public static bool Restore(Change change, TuningValue value) return null; } + // A number that is not one, or that does not fit the field's width. A mass of NaN is written as readily + // as a mass of 1200 and ships in the next build; an integer too wide for its field is cut to its low + // bytes and becomes some other number entirely. + private static string? WhyNot(ActorPropertyKind kind, TuningValue value) + { + (long Min, long Max)? range = kind switch + { + ActorPropertyKind.Bool => (0, 1), + ActorPropertyKind.Int8 => (sbyte.MinValue, sbyte.MaxValue), + ActorPropertyKind.UInt8 => (byte.MinValue, byte.MaxValue), + ActorPropertyKind.Int16 => (short.MinValue, short.MaxValue), + ActorPropertyKind.UInt16 => (ushort.MinValue, ushort.MaxValue), + ActorPropertyKind.Int32 => (int.MinValue, int.MaxValue), + ActorPropertyKind.UInt32 => (uint.MinValue, uint.MaxValue), + ActorPropertyKind.UInt64 => (0, long.MaxValue), + _ => null, + }; + return kind switch + { + ActorPropertyKind.Float when !float.IsFinite(value.X) => "the value is not a finite number", + ActorPropertyKind.Vector3 when !float.IsFinite(value.X) || !float.IsFinite(value.Y) || !float.IsFinite(value.Z) + => "the vector has a component that is not a finite number", + _ when range is { } r && (value.Number < r.Min || value.Number > r.Max) + => $"{value.Number} does not fit the field: it holds {r.Min} to {r.Max}", + _ => null, + }; + } + private static TuningValue Read(ActorPropertyField field) => field.Kind switch { ActorPropertyKind.Float => TuningValue.Of(field.Single), diff --git a/src/Illusion.Assets/Sds/OpenArchives.cs b/src/Illusion.Assets/Sds/OpenArchives.cs index 138f0d5..359ac23 100644 --- a/src/Illusion.Assets/Sds/OpenArchives.cs +++ b/src/Illusion.Assets/Sds/OpenArchives.cs @@ -56,6 +56,15 @@ public static void ReleaseAll(object owner) } } + /// The editors that have this archive loaded right now. + public static IReadOnlyList HoldersOf(FileInfo sds) + { + lock (Sync) + { + return Holders.TryGetValue(sds.FullName, out List? list) ? list.ToList() : []; + } + } + /// Whether anyone OTHER than already has this archive loaded — the one /// question worth asking before opening it a second time. public static bool IsHeldByAnyoneElse(FileInfo sds, object owner) diff --git a/src/Illusion.Assets/Sds/SdsWriter.cs b/src/Illusion.Assets/Sds/SdsWriter.cs index aaacae4..787c4d5 100644 --- a/src/Illusion.Assets/Sds/SdsWriter.cs +++ b/src/Illusion.Assets/Sds/SdsWriter.cs @@ -30,22 +30,29 @@ public static class SdsWriter /// Outcome of a single : the archive that was (re)written and the backup made of /// its previous contents, or null if no backup was created (disabled, or a brand-new archive). - public readonly record struct PackResult(string Archive, string? Backup); + public readonly record struct PackResult(string Archive, string? Backup) + { + /// Manifest entries that named a file missing from the working copy: left out of the archive + /// AND removed from the manifest. Never empty silently — a resource the archive no longer carries is + /// something the caller has to be able to say. + public IReadOnlyList Dropped { get; init; } = []; + } - /// - /// Drops manifest entries whose file is not on disk. Such an entry can only fail packing — the archive - /// would name a resource nothing carries — and it is unrecoverable from the folder's side, so removing it - /// is the only thing that lets a Build succeed at all. - /// - /// The file names that were unsaid. - internal static List PruneMissingEntries(string extracted) + /// The manifest entries of a working copy whose file is not on disk — what a pack would leave + /// out. Reads only. + public static IReadOnlyList MissingEntries(string extracted) { - var dropped = new List(); + ArgumentException.ThrowIfNullOrEmpty(extracted); SdsManifest manifest; try { manifest = SdsManifest.Load(extracted); } - catch (Exception ex) when (ex is IOException or SdsFormatException) { return dropped; } + catch (Exception ex) when (ex is IOException or SdsFormatException) { return []; } + return Missing(manifest, extracted); + } - foreach ((string type, string file) in manifest.Entries.ToArray()) + private static List Missing(SdsManifest manifest, string extracted) + { + var missing = new List(); + foreach ((string type, string file) in manifest.Entries) { // Containers list their pieces under elements of their own; their File is not a file. if (type is "Script" or "Table") continue; @@ -58,6 +65,26 @@ internal static List PruneMissingEntries(string extracted) // An XML resource is named without its extension and extracts to that name plus ".xml" — the // handler adds it on both sides. Missing that unsaid every XML of ingame.sds on its first Build. if (type == "XML" && File.Exists(onDisk + ".xml")) continue; + missing.Add(file); + } + return missing; + } + + /// + /// Drops manifest entries whose file is not on disk. Such an entry can only fail packing — the archive + /// would name a resource nothing carries — and it is unrecoverable from the folder's side, so removing it + /// is the only thing that lets a Build succeed at all. + /// + /// The file names that were unsaid. + internal static List PruneMissingEntries(string extracted) + { + var dropped = new List(); + SdsManifest manifest; + try { manifest = SdsManifest.Load(extracted); } + catch (Exception ex) when (ex is IOException or SdsFormatException) { return dropped; } + + foreach (string file in Missing(manifest, extracted)) + { if (manifest.RemoveEntry(file)) dropped.Add(file); } return dropped; @@ -140,10 +167,23 @@ public static string SaveFrameResource(FrameResource frame, FileInfo sds) sds.Refresh(); FileInfo? shipped = backups.Count > 0 ? backups[^1].File : sds.Exists ? sds : null; if (shipped == null) return null; + // Reading the figures means extracting the whole archive to a scratch folder. An archive that has + // none worth keeping — one the toolkit made — was asked again on every Build; the "no" is remembered + // for as long as that file is the same file. + shipped.Refresh(); + (long, DateTime) state = (shipped.Length, shipped.LastWriteTimeUtc); + lock (NoShippedFigures) + { + if (NoShippedFigures.TryGetValue(shipped.FullName, out (long, DateTime) known0) && known0 == state) return null; + } try { SdsMemoryRequirements read = SdsMemoryRequirements.FromArchive(shipped.FullName); - if (!read.LooksShipped) return null; + if (!read.LooksShipped) + { + lock (NoShippedFigures) NoShippedFigures[shipped.FullName] = state; + return null; + } read.Save(extracted); return read; } @@ -153,6 +193,9 @@ public static string SaveFrameResource(FrameResource frame, FileInfo sds) } } + private static readonly Dictionary NoShippedFigures = + new(StringComparer.OrdinalIgnoreCase); + /// Makes sure the working copy of knows its memory requirements — what a /// copy of that folder (a cloned car) then carries with it. public static bool EnsureMemoryRequirements(FileInfo sds) @@ -327,7 +370,7 @@ public static PackResult PackSds(FileInfo sds, bool createBackup, DateTime when) // such lines in — an added collision shape that was then undone used to leave one behind. Nothing on // disk can rescue such an entry, so drop it here rather than let it block every future Build; the // resource is already gone either way, and this is the only place that sees the pair. - PruneMissingEntries(extracted); + List dropped = PruneMissingEntries(extracted); // Pack to a temp archive beside the target, so a mid-write failure never touches the live game file. // SdsArchive.Pack/Save throw on failure (missing files, bad manifest), which propagates to the @@ -349,6 +392,6 @@ public static PackResult PackSds(FileInfo sds, bool createBackup, DateTime when) // never spawns a spurious version. string? backup = createBackup ? BackupArchive(sds, when) : null; File.Move(tmp.FullName, sds.FullName, overwrite: true); - return new PackResult(sds.FullName, backup); + return new PackResult(sds.FullName, backup) { Dropped = dropped }; } } diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index 420560e..f43e622 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -11,7 +11,10 @@ public sealed record EditorStatus( bool UnsavedEdits, IReadOnlyList PendingBuild, int BlenderObjects, - string RenderMode); + string RenderMode, + // Which editor the scene tools drive right now: "map" or "resource". A tool that only works on one of + // them switches to it, and this is where that shows. + string Target = "map"); /// What the resource editor is doing: whether it is open, which editor the scene tools drive /// (map or resource), and the archive on its stage. @@ -39,7 +42,9 @@ public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, i IReadOnlyList Packed, IReadOnlyList Notes); /// One archive a car clone wrote, and the backup of what it replaced. -public sealed record PackedArchive(string Archive, string? Backup); +/// are manifest entries that named a file missing from the working copy: they are +/// not in the archive, and no longer in the manifest. +public sealed record PackedArchive(string Archive, string? Backup, IReadOnlyList? Dropped = null); /// One car built under another's name: the model name the replaced archives are keyed by, each archive /// with the backup of what it held, and what was left as it was. @@ -90,10 +95,13 @@ public sealed record EditorNotice(DateTime Time, bool Error, string Text); /// What a Build wrote: each packed archive with the backup taken of what it replaced, and each /// archive that failed with the reason. is what the save a build starts with /// could not write; when it is not empty the build stopped there and packed nothing. +/// are manifest entries ("archive: file") that named a file missing from the +/// working copy and were left out of the archive packed. public sealed record BuildOutcome( IReadOnlyList<(string Archive, string? Backup)> Packed, IReadOnlyList<(string Archive, string Error)> Failed, - IReadOnlyList NotSaved); + IReadOnlyList NotSaved, + IReadOnlyList? Dropped = null); /// What mirroring a district into its winter archive did: how many objects had every material /// settled (winter's own where the season changes it), how many objects winter gained and lost, how many diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index da0fa3b..42bc94b 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -149,7 +149,7 @@ public interface IEditorSession /// edit made in the working copy itself (a script, a table) that no editor session tracks. With /// , the working copy first takes the memory requirements that archive states /// (a clone made before they were kept takes them from the car it was cloned from). Null on success. - string? BuildArchive(string archive, string? memoryFrom, out PackedArchive? result); + string? BuildArchive(string archive, string? memoryFrom, bool dropMissing, out PackedArchive? result); /// Builds one car under another car's name, replacing that car's archive (backup kept) and touching /// no table. Null on success. diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 06b21ad..2b00c56 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -183,7 +183,7 @@ public static async Task CarTuningSet( } [McpServerTool(Name = "car_clone")] - [Description("Make a new car out of an existing one for single player: copies pc\\sds\\cars\\.sds (and its winter _z twin) to .sds with the root frame, prefab entry, entity data and geometry buffers renamed, adds the car to vehicles.tbl under a new id (class, price and flags of the source), to PaintCombinations.tbl and AiProps, and — with traffic — to every traffic row that can pick the source. Then builds the new archives plus tables.sds and ingame.sds (backups kept). The game must not be running. Open the clone with resource_open and tune it with car_tuning_set.")] + [Description("Make a new car out of an existing one for single player: copies pc\\sds\\cars\\.sds (and its winter _z twin) to .sds with the root frame, prefab entry, entity data and geometry buffers renamed, adds the car to vehicles.tbl under a new id (class, price and flags of the source), to PaintCombinations.tbl and AiProps, and — with traffic — to every CARM* traffic row that can pick the source. Then builds the new archives plus tables.sds and ingame.sds (backups kept). Only the main game's tables are edited: the story DLCs ship an ingame.sds of their own and the clone is not in those (the result's notes say which). A source that is not keyed by its model name the way a stock car is, or anything failing on the way, is refused and everything written is taken back. A source open in an editor with unsaved edits is refused — save it first. The game must not be running. Open the clone with resource_open and tune it with car_tuning_set.")] public static async Task CarClone( IEditorSession editor, IUiThreadMarshal ui, @@ -210,12 +210,13 @@ public static async Task ArchiveBuild( IEditorSession editor, IUiThreadMarshal ui, [Description("Full path of the .sds archive in the game folder (pc\\sds\\… or pc\\dlcs\\…).")] string archive, + [Description("Build although the manifest names files that are not in the working copy. Default false: such a build is refused and the files are listed — put them back first. With true they are left out of the archive AND their manifest entries are removed for good; restoring a file later does not bring its entry back.")] bool dropMissing = false, [Description("Full path of an archive to take the per-resource memory requirements from, for an archive that never shipped itself: a cloned car built before requirements were kept takes them from the stock car it was cloned from. Omit normally — a working copy keeps the requirements of the archive it was extracted from.")] string? memoryFrom = null) { try { PackedArchive? result = null; - string? refused = await ui.RunAsync(() => editor.BuildArchive(archive, memoryFrom, out result)); + string? refused = await ui.RunAsync(() => editor.BuildArchive(archive, memoryFrom, dropMissing, out result)); return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, packed = result }); } catch (Exception ex) @@ -388,10 +389,16 @@ public static async Task BlenderOpen( while (true) { await Task.Delay(Poll); - EditorStatus status = await ui.RunAsync(editor.Status); + // Of the editor the session was opened in: with the resource editor as the target, the map's + // count stays at zero for good and this used to run out its whole timeout on a session that + // had opened in the first second. + ResourceStatus resource = await ui.RunAsync(editor.ResourceStatus); + int inBlender = resource.Target == "resource" + ? resource.BlenderObjects + : (await ui.RunAsync(editor.Status)).BlenderObjects; var said = (await ui.RunAsync(() => editor.Notices(8))).Where(n => n.Time >= started).ToList(); - if (status.BlenderObjects > 0) - return ToolResult.Json(new { success = true, blenderObjects = status.BlenderObjects, notices = said }); + if (inBlender > 0) + return ToolResult.Json(new { success = true, blenderObjects = inBlender, notices = said }); if (said.Any(n => n.Error)) return ToolResult.Json(new { success = false, error = "the editor refused", notices = said }); if (DateTime.UtcNow > deadline) diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index c55e9b5..3d462bf 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -343,6 +343,41 @@ void Check(string name, bool ok, string detail = "") && swapped.IndexBuffers.GetBuffer(l.IndexBufferRef.Hash) != null) && swapped.VertexBuffers.GetBuffer(Fnv64.Hash(Other + ".Root.L0.VB0")) != null); Check("substitute: packs and reads as a car", Packs(substitute) && Car.ReadFrom(substitute) != null); + + // A source that is not keyed by the name it is said to have: no root frame of that name, no + // prefab entry, entity data filed elsewhere. It used to be built all the same, with a note. + string unkeyed = Path.Combine(scratch, "unkeyed"); + string? refusedBecause = null; + try + { + CarCloner.SubstituteExtracted(cars[0].To, unkeyed, "Not_Its_Name", Other); + } + catch (InvalidDataException ex) + { + refusedBecause = ex.Message; + } + Check("substitute: a car that is not keyed by the model name given is a failure, not a note", + refusedBecause != null, refusedBecause ?? "built without complaint"); + + // What a pack would leave out, asked before packing. + string shortOf = Path.Combine(scratch, "short"); + Directory.CreateDirectory(shortOf); + foreach (string file in Directory.GetFiles(substitute, "*", SearchOption.AllDirectories)) + { + string copy = Path.Combine(shortOf, Path.GetRelativePath(substitute, file)); + Directory.CreateDirectory(Path.GetDirectoryName(copy)!); + File.Copy(file, copy); + } + string? gone = Directory.GetFiles(shortOf, "*.dds").FirstOrDefault(); + if (gone != null) + { + File.Delete(gone); + IReadOnlyList missing = Illusion.Assets.Sds.SdsWriter.MissingEntries(shortOf); + Check("a working copy short of a file its manifest names says which, before any pack", + missing.Count == 1 && missing.Any(m => string.Equals(m.TrimStart('/', '\\'), Path.GetFileName(gone), StringComparison.OrdinalIgnoreCase)) + && Illusion.Assets.Sds.SdsWriter.MissingEntries(substitute).Count == 0, + $"{missing.Count} missing: " + string.Join(", ", missing.Take(4))); + } } // A rebuilt name table has to be the table the archive shipped with — order included. diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 968a4fc..e7f403c 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -131,6 +131,31 @@ public ResourceStatus ResourceStatus() } if (ResourceWindow is not { } window) return "the resource editor did not open"; if (window.TargetStage is { } stage && stage.BridgeEditedCount > 0) return "a Blender edit session is open there — blender_end first"; + + // Already on the stage: nothing to load. Staging it again would reload it — and a reload empties the + // undo history, so the editor_undo after a few car_tuning_set calls found nothing to undo. + if (string.Equals(window.StagedEntry?.File.FullName, sds.FullName, StringComparison.OrdinalIgnoreCase)) + { + _resourceTarget = true; + archivePath = sds.FullName; + return null; + } + // The window answers two situations with a dialog, and a dialog here is a tool call that does not + // return until a person clicks. Both are settled before it is asked. + if (Assets.Sds.OpenArchives.IsHeldByAnyoneElse(sds, window.Stage)) + { + return $"{sds.Name} is loaded in another editor window — two editors on one working copy overwrite each " + + "other's saves. Work on it there (editor_target map), or load something else there first"; + } + if (window.Stage.HasUnsavedEdits) + { + D3DImageHost.SaveReport saved = window.Stage.SaveEditsReport(); + if (!saved.Complete) + { + return "what is on the stage has edits that could not be saved, and opening another archive would drop them: " + + string.Join("; ", saved.NotSaved); + } + } window.Reveal(sds); _resourceTarget = true; archivePath = sds.FullName; @@ -154,7 +179,7 @@ public EditorStatus Status() { if (Window is not { } window) { - return new EditorStatus(false, null, false, false, 0, [], false, [], 0, ""); + return new EditorStatus(false, null, false, false, 0, [], false, [], 0, "", _resourceTarget ? "resource" : "map"); } D3DImageHost host = window.Viewport; @@ -168,6 +193,7 @@ public EditorStatus Status() Loading: host.IsLoading, Meshes: host.MeshCount, Selection: host.SelectedNodes.Select(n => n.Name).ToList(), + Target: _resourceTarget ? "resource" : "map", UnsavedEdits: host.HasUnsavedEdits, PendingBuild: host.PendingBuildArchives().Select(f => f.Name).ToList(), BlenderObjects: host.BridgeEditedCount, @@ -213,6 +239,10 @@ public IReadOnlyList Areas() => if (window.WholeMapCheck.IsChecked == true) window.WholeMapCheck.IsChecked = false; if ((window.WinterToggle.IsChecked == true) != winter) window.WinterToggle.IsChecked = winter; if (!ReferenceEquals(window.AreaCombo.SelectedItem, target)) window.AreaCombo.SelectedItem = target; + // Opening an area is asking for the map, the way resource_open is asking for the resource editor. + // Left on the resource editor, the find, select, delete, save and build that follow an + // editor_open_area went on acting on whatever car was on its stage. + _resourceTarget = false; return null; } @@ -465,7 +495,8 @@ public BuildOutcome Build() return new BuildOutcome( report.Packed.Select(p => (p.Archive, p.Backup)).ToList(), report.Failed.Select(f => (f.Archive, f.Error)).ToList(), - []); + [], + [.. report.Packed.SelectMany(p => (p.Dropped ?? []).Select(file => $"{Path.GetFileName(p.Archive)}: {file}"))]); } public string? MirrorToWinter(out SeasonMirrorOutcome? outcome) @@ -495,6 +526,7 @@ public BuildOutcome Build() if (report == null) return reason ?? "the winter archive could not be written"; host.MarkArchiveModified(area.Winter); + _resourceTarget = false; // a map-only tool: what follows it is about the map outcome = new SeasonMirrorOutcome(area.Winter.FullName, report.Matched, report.Added, report.Dropped, report.Reassigned, report.Ambiguous, report.Files, report.Textures); host.RaiseNotice( @@ -645,7 +677,9 @@ public CameraInfo Camera() SceneNode? node = host.ActorEditing.Import( actorsRow, source, actor, newName, new Vector3(position[0], position[1], position[2]), out string? reason); - return node == null ? reason ?? "the pack refused the actor" : null; + 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, @@ -668,8 +702,10 @@ public CameraInfo Camera() { return $"collision '{collision}' is none of auto, convex, box, mesh, none"; } - return host.ObjectImporting.Import(destination, sourceArchive, name, newName, + 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; } public string? DuplicateSelected(out IReadOnlyList copies) @@ -920,9 +956,12 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) return $"'{value}' is not a {target.Kind} value"; } - Assets.EntityData.TuningEditing.Change? change = - Assets.EntityData.TuningEditing.Set(chosen.Path, chosen.Index, target.Offset, parsed, target.Name); - if (change == null) return $"{target.Name} could not be written"; + Assets.EntityData.TuningEditing.Change? change = Assets.EntityData.TuningEditing.Set( + chosen.Path, chosen.Index, target.Offset, parsed, target.Name, out string? unwritten); + if (change == null) return $"{target.Name} was not written: {unwritten ?? "the field could not be found"}"; + // The undo entry and the build list below are the resource editor's; with the map as the target the + // editor_undo and editor_build that follow would act on the map and leave this where it is. + _resourceTarget = true; // The same bookkeeping the Tuning tab does: an undo entry, the archive on the build list, the panel re-read. D3DImageHost stage = window.TargetStage; @@ -940,7 +979,7 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) return null; } - public string? BuildArchive(string archive, string? memoryFrom, out PackedArchive? result) + public string? BuildArchive(string archive, string? memoryFrom, bool dropMissing, out PackedArchive? result) { result = null; if (EnsureEnvironment() is { } notReady) return notReady; @@ -960,8 +999,18 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) if (!Path.IsPathRooted(memoryFrom) || !reference.Exists) return $"no such archive to take memory requirements from: {memoryFrom}"; Assets.Sds.SdsWriter.AdoptMemoryRequirements(sds, reference); } + // This tool is for edits made by hand in the working copy, where a renamed or deleted file is a + // slip as often as an intention — and a pack does not skip such an entry quietly: it leaves the + // resource out AND unsays it in the manifest, so putting the file back later changes nothing. + IReadOnlyList missing = Assets.Sds.SdsWriter.MissingEntries(extracted); + if (missing.Count > 0 && !dropMissing) + { + return $"{missing.Count} file(s) the manifest names are not in the working copy: " + + string.Join(", ", missing.Take(12)) + (missing.Count > 12 ? ", …" : "") + + " — put them back, or pass dropMissing=true to build without them (their manifest entries are then removed for good)"; + } Assets.Sds.SdsWriter.PackResult packed = Assets.Sds.SdsWriter.PackSds(sds, createBackup: true); - result = new PackedArchive(packed.Archive, packed.Backup); + result = new PackedArchive(packed.Archive, packed.Backup, packed.Dropped.Count > 0 ? packed.Dropped : null); return null; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Formats.FileFormatException) @@ -975,6 +1024,13 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) result = null; if (EnsureEnvironment() is { } notReady) return notReady; if (string.IsNullOrWhiteSpace(source) || string.IsNullOrWhiteSpace(target)) return "name the car and the one it replaces"; + if (OpenWithEdits(source) is { } unsaved) return unsaved; + // The target's working copy is about to be replaced under whoever has it loaded: an editor that then + // saves writes the OLD car's scene into the new car's folder. + if (CarArchives(target).FirstOrDefault(a => Assets.Sds.OpenArchives.HoldersOf(a).Count > 0) is { } held) + { + return $"{held.Name} is open in an editor — its working copy is about to be replaced; open something else there first"; + } try { if (Assets.Cars.CarCloner.Substitute(source, target, out string? refusal) is not { } outcome) return refusal; @@ -1015,6 +1071,7 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) { result = null; if (EnsureEnvironment() is { } notReady) return notReady; + if (OpenWithEdits(source) is { } unsaved) return unsaved; try { if (Assets.Cars.CarCloner.Clone(source, name, traffic, title, out string? refusal) is not { } outcome) return refusal; @@ -1028,6 +1085,31 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) } } + // A car's archives, summer and winter, by archive or model name. + private static IEnumerable CarArchives(string car) + { + string stem = Path.GetFileNameWithoutExtension(car).ToLowerInvariant(); + foreach (string suffix in new[] { "", "_z" }) + { + yield return new FileInfo(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", "cars", stem + suffix + ".sds")); + } + } + + // A clone and a substitution are made from the working copy ON DISK. A car open in an editor with edits + // not yet saved would be copied without them, and nothing would say so. + private static string? OpenWithEdits(string car) + { + foreach (FileInfo archive in CarArchives(car)) + { + if (Assets.Sds.OpenArchives.HoldersOf(archive).OfType().Any(h => h.HasUnsavedEdits)) + { + return $"{archive.Name} is open in an editor with unsaved edits — editor_save first, " + + "or the copy would be made from what is on disk, without them"; + } + } + return null; + } + public string? Undo() { if (TargetHost is not { } host) return TargetNotOpen; diff --git a/src/Illusion/Views/MainWindow.xaml.cs b/src/Illusion/Views/MainWindow.xaml.cs index 6e88cae..a384db5 100644 --- a/src/Illusion/Views/MainWindow.xaml.cs +++ b/src/Illusion/Views/MainWindow.xaml.cs @@ -408,6 +408,14 @@ private void Build_Click(object sender, RoutedEventArgs e) private void ShowBuildResult(D3DImageHost.BuildReport report) { bool anyFailed = report.Failed.Count > 0; + // Entries a pack left out because their file was not in the working copy. Never silenced: the archive + // is short of a resource, and nothing else would say so. + List dropped = [.. report.Packed.SelectMany(p => (p.Dropped ?? []).Select(f => $"{Path.GetFileName(p.Archive)}: {f}"))]; + if (dropped.Count > 0) + { + PostNotice($"{dropped.Count} manifest entr(ies) named a file missing from the working copy and were left out of " + + "the build: " + string.Join(", ", dropped.Take(6)) + (dropped.Count > 6 ? ", …" : ""), true); + } if (!anyFailed && UserSettings.Current.SuppressBuildNotice) return; // user silenced successful-build notices var msg = new StringBuilder(); diff --git a/src/Illusion/Views/ResourceEditorWindow.xaml.cs b/src/Illusion/Views/ResourceEditorWindow.xaml.cs index 18e683f..061c81e 100644 --- a/src/Illusion/Views/ResourceEditorWindow.xaml.cs +++ b/src/Illusion/Views/ResourceEditorWindow.xaml.cs @@ -55,8 +55,11 @@ public ResourceEditorWindow() UpdateTitle(); CommandManager.InvalidateRequerySuggested(); }); - Stage.TransientNotice += (message, isError) => Notices.Post(message, isError); - Stage.BridgeNotice += (message, isError) => Notices.Post(message, isError); + // Into the application's notice log as well as onto the banner: editor_notices and the Blender tools + // read the log, and with this window as their target they were waiting for lines only the map + // editor ever wrote — a push that had landed was reported as "no push arrived". + Stage.TransientNotice += PostNotice; + Stage.BridgeNotice += PostNotice; // The same tools the map editor has, over the same kind of viewport: select / move / rotate / scale, // walk mode, and the Blender bridge. The bridge refuses skinned geometry (a car body is exactly that @@ -528,14 +531,29 @@ private void Build_Click(object sender, RoutedEventArgs e) finally { Mouse.OverrideCursor = null; } } + private void PostNotice(string message, bool isError) + { + Mcp.EditorNoticeLog.Add(message, isError); + Notices.Post(message, isError); + } + // Short and to the point: the map editor's version offers a "don't show again" for successful builds, // which only earns its keep when you build district after district. private void ShowBuildResult(Viewport.D3DImageHost.BuildReport report) { + // Entries a pack left out because their file was not in the working copy: said, and as an error — + // the archive is short of a resource, whatever else went well. + List dropped = [.. report.Packed.SelectMany(p => (p.Dropped ?? []).Select(f => $"{Path.GetFileName(p.Archive)}: {f}"))]; + if (dropped.Count > 0) + { + PostNotice($"{dropped.Count} manifest entr(ies) named a file missing from the working copy and were left out: " + + string.Join(", ", dropped.Take(6)) + (dropped.Count > 6 ? ", …" : ""), true); + } if (report.Failed.Count == 0) { string? backup = report.Packed.Select(r => r.Backup).FirstOrDefault(b => b != null); - Notices.Post(report.Packed.Count == 1 ? "Built 1 archive." : $"Built {report.Packed.Count} archives." + if (dropped.Count > 0) return; // the line above is the one to read + PostNotice(report.Packed.Count == 1 ? "Built 1 archive." : $"Built {report.Packed.Count} archives." + (backup != null ? " Backup: " + Path.GetDirectoryName(backup) : ""), false); return; } From 91722f3446b1f5b9c2882f1138719cda4c558352 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:25:33 +0300 Subject: [PATCH 052/102] fix(import): the texture index keeps up with the mirror Found reviewing this branch. The index a third-archive lookup goes by was one scan, made once: an archive extracted later in the session was not in it (import a shop gun before weapons.sds was ever opened - 'in no extracted archive' - open weapons.sds, import again: the same answer until a restart), and it kept one path per name, so when that path was a copy the toolkit had carried into a working copy and later took out again, the lookup answered with a file that was not there and the texture was reported as held nowhere although its own archive had it. Every folder holding a name is remembered now (as an index into a list of folders, not sixty thousand paths), an answer is checked against the disk and falls through to the next copy when the first is gone, and the extractor announces the folder it has just made (RegisterFolder). --probe-object-transplant 60/60 (2 new: a folder announced after the scan; a name held twice - first copy, second when the first is gone, nothing when both are). --probe-material-editor 65/65. Not checked: the shop-gun scenario itself in the editor. --- src/Illusion.Assets/Sds/SdsMeshLoader.cs | 4 + .../Textures/TextureSearchIndex.cs | 100 ++++++++++++++---- .../Probes/ObjectTransplantProbes.cs | 25 +++++ 3 files changed, 108 insertions(+), 21 deletions(-) diff --git a/src/Illusion.Assets/Sds/SdsMeshLoader.cs b/src/Illusion.Assets/Sds/SdsMeshLoader.cs index 1c04d52..1ecf3c6 100644 --- a/src/Illusion.Assets/Sds/SdsMeshLoader.cs +++ b/src/Illusion.Assets/Sds/SdsMeshLoader.cs @@ -48,10 +48,14 @@ public static string EnsureExtracted(FileInfo sdsFile) if (File.Exists(Path.Combine(legacy, "SDSContent.xml"))) { RelocateToResources(legacy, target); + Textures.TextureSearchIndex.RegisterFolder(target); return target; } SdsArchive.Open(sdsFile.FullName).Extract(target); + // The texture index is a scan made once; what this archive brings into the mirror has to be told + // to it, or its textures stay "in no extracted archive" for the rest of the session. + Textures.TextureSearchIndex.RegisterFolder(target); return target; } } diff --git a/src/Illusion.Assets/Textures/TextureSearchIndex.cs b/src/Illusion.Assets/Textures/TextureSearchIndex.cs index 853809f..da3610d 100644 --- a/src/Illusion.Assets/Textures/TextureSearchIndex.cs +++ b/src/Illusion.Assets/Textures/TextureSearchIndex.cs @@ -3,19 +3,36 @@ namespace Illusion.Assets.Textures; /// /// Global .dds lookup across the WHOLE resources mirror (every extracted SDS, ~1600 folders / ~55k files /// on a full install) — so the material editor resolves any texture the game ships, not only the folders -/// of currently loaded districts. Built once per session by one recursive scan; runs -/// it in the background during catalog init so the first material click doesn't block. First path wins per -/// name — duplicates across archives are shipping copies of the same texture. Textures of an SDS that was -/// never extracted are not on disk and therefore not found — extraction happens through normal use. +/// of currently loaded districts. Built by one recursive scan; runs it in the background +/// during catalog init so the first material click doesn't block. The first folder found wins per name — +/// duplicates across archives are shipping copies of the same texture. +/// +/// The scan is a picture of one moment, and the mirror changes under it: an archive extracted later in the +/// session brings textures the scan never saw ( — the extractor calls it), and a +/// texture the toolkit itself carried into a working copy can be taken out of it again. So every folder that +/// holds a name is remembered, not only the first, and an answer is checked against the disk before it is +/// given: a copy that has gone falls through to the next one instead of answering with a path to nothing. +/// /// public static class TextureSearchIndex { private static readonly object Sync = new(); - private static volatile Dictionary? _byName; - public static bool IsBuilt => _byName != null; + // name → the folders that hold a file of that name, first found first. Folders are kept once and named by + // their place in the list: sixty thousand paths would otherwise repeat a few hundred folder names. + private static Dictionary>? _byName; + private static readonly List Folders = new(); + private static readonly Dictionary FolderIds = new(StringComparer.OrdinalIgnoreCase); - public static int Count => _byName?.Count ?? 0; + public static bool IsBuilt + { + get { lock (Sync) return _byName != null; } + } + + public static int Count + { + get { lock (Sync) return _byName?.Count ?? 0; } + } /// Builds the index in the background (no-op when already built / environment not ready). public static void WarmUp() => Task.Run(() => @@ -28,43 +45,84 @@ public static void WarmUp() => Task.Run(() => /// has no initialized game path. public static void EnsureBuilt() { - if (_byName != null) return; lock (Sync) { if (_byName != null) return; string? resources = MafiaEnvironment.IsInitialized ? MafiaEnvironment.ResourcesFolder : null; if (resources == null || !Directory.Exists(resources)) return; - var map = new Dictionary(StringComparer.OrdinalIgnoreCase); + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); foreach (string path in Directory.EnumerateFiles(resources, "*.dds", SearchOption.AllDirectories)) { - string name = Path.GetFileName(path); - if (!map.ContainsKey(name)) map[name] = path; + Add(map, path); } - _byName = map; // published last — readers never see a half-filled index + _byName = map; // set last — a failed scan leaves the index unbuilt, to be tried again + } + } + + // Caller holds Sync. + private static void Add(Dictionary> map, string path) + { + string? folder = Path.GetDirectoryName(path); + if (string.IsNullOrEmpty(folder)) return; + if (!FolderIds.TryGetValue(folder, out int id)) + { + FolderIds[folder] = id = Folders.Count; + Folders.Add(folder); } + string name = Path.GetFileName(path); + if (!map.TryGetValue(name, out List? holders)) map[name] = holders = new List(1); + if (!holders.Contains(id)) holders.Add(id); } /// Announces a texture the toolkit just wrote into the mirror, so it resolves without a - /// rescan. An existing name keeps its first path (the first-wins rule of the scan). No-op while the - /// index is unbuilt — the eventual scan finds the file on disk by itself. + /// rescan. A name already known keeps the folder it was first found in; this one is remembered behind + /// it. No-op while the index is unbuilt — the eventual scan finds the file on disk by itself. public static void Register(string path) { + ArgumentException.ThrowIfNullOrEmpty(path); lock (Sync) { - Dictionary? current = _byName; - string name = Path.GetFileName(path); - if (current == null || current.ContainsKey(name)) return; - _byName = new Dictionary(current, StringComparer.OrdinalIgnoreCase) { [name] = path }; + if (_byName != null) Add(_byName, path); } } - /// Full path of a texture name anywhere in the mirror, or null. Blocks on the first call - /// if the background build has not finished yet (WarmUp makes that rare). + /// + /// Announces every texture of a folder that has just appeared in the mirror — an archive extracted after + /// the scan. Without it, a texture that lives in an archive opened later in the session stayed "in no + /// extracted archive" until the program was restarted. No-op while the index is unbuilt. + /// + public static void RegisterFolder(string folder) + { + ArgumentException.ThrowIfNullOrEmpty(folder); + lock (Sync) + { + if (_byName == null || !Directory.Exists(folder)) return; + foreach (string path in Directory.EnumerateFiles(folder, "*.dds", SearchOption.AllDirectories)) + { + Add(_byName, path); + } + } + } + + /// Full path of a texture name anywhere in the mirror, or null — a file that is there now: a + /// copy that has been removed since it was indexed is forgotten and the next one answers. Blocks on the + /// first call if the background build has not finished yet (WarmUp makes that rare). public static string? FindPath(string? name) { if (string.IsNullOrEmpty(name)) return null; EnsureBuilt(); - return _byName != null && _byName.TryGetValue(name, out string? path) ? path : null; + lock (Sync) + { + if (_byName == null || !_byName.TryGetValue(name, out List? holders)) return null; + while (holders.Count > 0) + { + string path = Path.Combine(Folders[holders[0]], name); + if (File.Exists(path)) return path; + holders.RemoveAt(0); + } + _byName.Remove(name); + return null; + } } } diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index f5eb344..7ba7c7a 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -418,6 +418,31 @@ void PutBack() carriedScenery.DirectTextures); } + // ── The texture index: a scan made once, kept true as the mirror changes under it ── + Illusion.Assets.Textures.TextureSearchIndex.EnsureBuilt(); + if (Illusion.Assets.Textures.TextureSearchIndex.IsBuilt) + { + string indexA = Path.Combine(scratch, "index_a"), indexB = Path.Combine(scratch, "index_b"), late = Path.Combine(scratch, "index_late"); + foreach (string made in new[] { indexA, indexB, late }) Directory.CreateDirectory(made); + const string Twice = "illusion_probe_index_twice.dds", Late = "illusion_probe_index_late.dds"; + File.WriteAllBytes(Path.Combine(indexA, Twice), [1]); + File.WriteAllBytes(Path.Combine(indexB, Twice), [2]); + File.WriteAllBytes(Path.Combine(late, Late), [3]); + bool unknown = Illusion.Assets.Textures.TextureSearchIndex.FindPath(Late) == null; + Illusion.Assets.Textures.TextureSearchIndex.RegisterFolder(late); + Check("a folder that appears after the scan is found once it is announced", + unknown && string.Equals(Illusion.Assets.Textures.TextureSearchIndex.FindPath(Late), Path.Combine(late, Late), StringComparison.OrdinalIgnoreCase)); + Illusion.Assets.Textures.TextureSearchIndex.Register(Path.Combine(indexA, Twice)); + Illusion.Assets.Textures.TextureSearchIndex.Register(Path.Combine(indexB, Twice)); + bool firstWins = string.Equals(Illusion.Assets.Textures.TextureSearchIndex.FindPath(Twice), Path.Combine(indexA, Twice), StringComparison.OrdinalIgnoreCase); + File.Delete(Path.Combine(indexA, Twice)); + bool fallsThrough = string.Equals(Illusion.Assets.Textures.TextureSearchIndex.FindPath(Twice), Path.Combine(indexB, Twice), StringComparison.OrdinalIgnoreCase); + File.Delete(Path.Combine(indexB, Twice)); + Check("a name held twice answers with the first copy, with the second when the first is gone, with nothing when both are", + firstWins && fallsThrough && Illusion.Assets.Textures.TextureSearchIndex.FindPath(Twice) == null, + $"first {firstWins}, second {fallsThrough}"); + } + // ── A destination that has neither a prefab nor an item description of its own ── string bare = Path.Combine(scratch, "bare"); Directory.CreateDirectory(bare); From 1e49f4445d99a7b140bb8097a7e3f692dcea0afc Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:39:49 +0300 Subject: [PATCH 053/102] fix(import): review of #6 - links by place, blocks shared only when alike, the Props tab paged What the review of this branch found that the commits before this one had not already answered (the failed-import rollback, the swept textures and the numeric-edit path are in 56f5f8d and 45236da). A collision link is a placement, found by its place. The record kept one hash per hull: two placements of the same hull collapsed into one entry (Distinct), a resize re-cooked the hull under a new hash the record did not learn, and a placement was looked for within 5 m of the object's pivot, which says nothing about where a placement's origin is. Each entry is now one placement - the hull and where it stood in the object's own space when the link was made - and it is found where the object's matrix now puts that point, which every move, turn and resize the two make together leaves unchanged. The same hull twice is two entries; a resize rewrites the hashes in the record as an edit inside the same undo step; a record written before this (bare hashes) is read as before and matched by nearness to the object's box. A geometry block is shared only when it draws the same way. Matching buffers and decompression values was taken as "the same block", so a second import inherited whatever had been done to the first copy's block since - a draw distance set to 0, and an object that does not draw. The draw distance, vertex layout and count and both capsules are compared (FrameLOD.DrawsLike); the buffers are still shared, the block is copied when it differs. The Props tab makes cards a page at a time. A wrapping panel does not virtualize, and the whole catalog - 2,900 objects here - was given cards at once, about two seconds of the UI thread on opening and on every filter change. 120 cards, and the next 120 when the end of them comes within reach; pictures go to the cards in view first. The picture timer, stopped when the tab is hidden, starts again when it is shown - a catalog already loaded had nothing to restart it. --probe-object-import-live 36/36 (6 new: a resize gives the hull a new hash and the record follows, undo puts both back; a second placement of the same hull nine metres from the pivot is the object's too, and both go with it). --probe-object-transplant 54/54 (a copy whose draw distance was changed does not lend its block). --probe-prop-catalog 12/12 (the tab in a 330x700 window: 120 cards for 2928 objects, 240 after scrolling to the end, pictures resume after hide and show). Not checked: a record in the old format on a real working copy. --- src/Illusion.Assets/Frames/FrameTransplant.cs | 7 +- src/Illusion.Assets/Sds/ImportLinks.cs | 143 ++++++++++++++---- .../Frames/Resources/FrameLOD.cs | 13 ++ .../Probes/ObjectImportLiveProbes.cs | 65 +++++++- .../Probes/ObjectTransplantProbes.cs | 15 ++ .../Diagnostics/Probes/PropCatalogProbes.cs | 54 +++++++ src/Illusion/Viewport/D3DImageHost.cs | 78 +++++++--- src/Illusion/Viewport/ImportLinkEdit.cs | 4 +- .../Viewport/ObjectImportController.cs | 2 +- .../Viewport/TransformEditController.cs | 58 ++++++- .../Views/ScenePanel/Tabs/PropsTabView.xaml | 1 + .../ScenePanel/Tabs/PropsTabView.xaml.cs | 64 +++++++- 12 files changed, 425 insertions(+), 79 deletions(-) diff --git a/src/Illusion.Assets/Frames/FrameTransplant.cs b/src/Illusion.Assets/Frames/FrameTransplant.cs index 08bd1c7..5da3bb6 100644 --- a/src/Illusion.Assets/Frames/FrameTransplant.cs +++ b/src/Illusion.Assets/Frames/FrameTransplant.cs @@ -524,10 +524,14 @@ private static void CopyBlocks(FrameResource resource, FrameObjectSingleMesh ori } // Whether a block of this scene draws exactly what the source block does, from the buffers it was copied to. + // "Exactly" is the whole block: a copy made earlier is the user's to edit, and one whose draw distance was + // set to 0 since — or whose level was rebuilt — is no longer the source's block. Sharing it gave the next + // import that edit too: an object that does not draw. The buffers are still shared; the block is copied. private static bool Draws(FrameGeometry candidate, FrameGeometry original, Dictionary vertexNames, Dictionary indexNames) { if (candidate.LOD is not { } lods || lods.Length != original.LOD.Length) return false; + if (candidate.NumLods != original.NumLods || candidate.Unk01 != original.Unk01) return false; if (candidate.DecompressionOffset != original.DecompressionOffset || candidate.DecompressionFactor != original.DecompressionFactor) { return false; @@ -535,7 +539,8 @@ private static bool Draws(FrameGeometry candidate, FrameGeometry original, Dicti for (int i = 0; i < lods.Length; i++) { if (lods[i].VertexBufferRef.Hash != vertexNames[original.LOD[i].VertexBufferRef.Hash].Hash - || lods[i].IndexBufferRef.Hash != indexNames[original.LOD[i].IndexBufferRef.Hash].Hash) + || lods[i].IndexBufferRef.Hash != indexNames[original.LOD[i].IndexBufferRef.Hash].Hash + || !lods[i].DrawsLike(original.LOD[i])) { return false; } diff --git a/src/Illusion.Assets/Sds/ImportLinks.cs b/src/Illusion.Assets/Sds/ImportLinks.cs index f70384b..ce66941 100644 --- a/src/Illusion.Assets/Sds/ImportLinks.cs +++ b/src/Illusion.Assets/Sds/ImportLinks.cs @@ -1,3 +1,4 @@ +using System.Numerics; using System.Text.Json; namespace Illusion.Assets.Sds; @@ -9,23 +10,35 @@ namespace Illusion.Assets.Sds; /// Nothing in the archive ties a static object to its collision: the hull is a placement in the collision /// resource that happens to stand where the object stands. For the objects this toolkit carried in, the tie is /// written down here, beside the working copy and outside its manifest (the game never sees it): the object's -/// name and the hashes of the hulls it was given. The placements themselves are found again by hash, nearest -/// first — their positions change whenever the object is moved, their hulls do not — and each placement goes -/// to one object only: a copy stands on top of its original with the very same hull, and must not take it. +/// name and, for each placement it was given, the hull it places and where that placement stood IN THE +/// OBJECT'S OWN SPACE when the link was made. +/// +/// +/// The place is what identifies a placement. A hull hash names a shape, not a placement — one object can be +/// given the same hull twice — and it changes when a scale re-cooks the hull; a distance from the object's +/// pivot says little, since a placement's origin can be metres from the pivot of what it belongs to. The +/// place in the object's space stays what it was through every move, turn and resize the two make together, +/// so the placement is found again where the object's matrix now puts that point. A record written before +/// places were kept has only the hash, and is matched by nearness to the object as it used to be. /// /// public static class ImportLinks { private const string FileName = "illusion_links.json"; - /// Records the hulls an object was given, replacing whatever an earlier object of that name had. - public static void Set(string extractedDir, string frameName, IEnumerable hulls) + /// One placement an object was given: the hull it places, and where it stood in the object's own + /// space when the link was made — null in a record older than that. + public readonly record struct Link(ulong Hull, Vector3? At); + + /// Records the placements an object was given, replacing whatever an earlier object of that name + /// had. One entry per placement: the same hull given twice is two entries. + public static void Set(string extractedDir, string frameName, IEnumerable hulls) { ArgumentException.ThrowIfNullOrEmpty(extractedDir); ArgumentException.ThrowIfNullOrEmpty(frameName); ArgumentNullException.ThrowIfNull(hulls); - Dictionary> links = Read(extractedDir); - List list = [.. hulls.Distinct()]; + Dictionary> links = Read(extractedDir); + List list = [.. hulls]; if (list.Count == 0) links.Remove(frameName); else links[frameName] = list; Write(extractedDir, links); @@ -37,19 +50,69 @@ public static void Rename(string extractedDir, string oldName, string newName) { ArgumentException.ThrowIfNullOrEmpty(extractedDir); if (string.IsNullOrEmpty(oldName) || string.IsNullOrEmpty(newName) || oldName == newName) return; - Dictionary> links = Read(extractedDir); - if (!links.Remove(oldName, out List? hulls)) return; + Dictionary> links = Read(extractedDir); + if (!links.Remove(oldName, out List? hulls)) return; links[newName] = hulls; Write(extractedDir, links); } - private static void Write(string extractedDir, Dictionary> links) + /// The placements recorded for an object of this archive, or none. + public static IReadOnlyList HullsOf(string extractedDir, string frameName) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + return frameName is { Length: > 0 } && Read(extractedDir).TryGetValue(frameName, out List? hulls) ? hulls : []; + } + + /// Every object of the archive that has placements recorded, with them. + public static IReadOnlyDictionary> All(string extractedDir) + { + ArgumentException.ThrowIfNullOrEmpty(extractedDir); + return Read(extractedDir); + } + + // On disk: name → a list whose items are either a bare number (a hull hash — the form every record had + // before places were kept, still read and still written for a link that has no place) or + // { "h": hash, "at": [x, y, z] }. + private static void Write(string extractedDir, Dictionary> links) { string path = Path.Combine(extractedDir, FileName); try { - if (links.Count == 0) File.Delete(path); - else AtomicFile.WriteAllBytes(path, JsonSerializer.SerializeToUtf8Bytes(links)); + if (links.Count == 0) + { + File.Delete(path); + } + else + { + using var stream = new MemoryStream(); + using (var json = new Utf8JsonWriter(stream)) + { + json.WriteStartObject(); + foreach ((string name, List hulls) in links.OrderBy(l => l.Key, StringComparer.Ordinal)) + { + json.WriteStartArray(name); + foreach (Link link in hulls) + { + if (link.At is not { } at) + { + json.WriteNumberValue(link.Hull); + continue; + } + json.WriteStartObject(); + json.WriteNumber("h", link.Hull); + json.WriteStartArray("at"); + json.WriteNumberValue(at.X); + json.WriteNumberValue(at.Y); + json.WriteNumberValue(at.Z); + json.WriteEndArray(); + json.WriteEndObject(); + } + json.WriteEndArray(); + } + json.WriteEndObject(); + } + AtomicFile.WriteAllBytes(path, stream.ToArray()); + } } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { @@ -60,24 +123,10 @@ private static void Write(string extractedDir, Dictionary> l // The file as last read, by path, with the time stamp it had: every drag start, delete and duplicate asks // about every node it touches, and parsing the file for each of them is what made a large selection stall. - private static readonly Dictionary> Links)> Cache = + private static readonly Dictionary> Links)> Cache = new(StringComparer.OrdinalIgnoreCase); - /// The hulls recorded for an object of this archive, or none. - public static IReadOnlyList HullsOf(string extractedDir, string frameName) - { - ArgumentException.ThrowIfNullOrEmpty(extractedDir); - return frameName is { Length: > 0 } && Read(extractedDir).TryGetValue(frameName, out List? hulls) ? hulls : []; - } - - /// Every object of the archive that has hulls recorded, with them. - public static IReadOnlyDictionary> All(string extractedDir) - { - ArgumentException.ThrowIfNullOrEmpty(extractedDir); - return Read(extractedDir); - } - - private static Dictionary> Read(string extractedDir) + private static Dictionary> Read(string extractedDir) { string path = Path.Combine(extractedDir, FileName); try @@ -88,18 +137,48 @@ private static Dictionary> Read(string extractedDir) { if (Cache.TryGetValue(path, out var kept) && kept.Stamp == stamp) return Copy(kept.Links); } - Dictionary> links = - JsonSerializer.Deserialize>>(File.ReadAllText(path)) ?? new(); + + var links = new Dictionary>(); + using (JsonDocument document = JsonDocument.Parse(File.ReadAllBytes(path))) + { + if (document.RootElement.ValueKind != JsonValueKind.Object) return new(); + foreach (JsonProperty entry in document.RootElement.EnumerateObject()) + { + if (entry.Value.ValueKind != JsonValueKind.Array) continue; + var hulls = new List(); + foreach (JsonElement item in entry.Value.EnumerateArray()) + { + if (item.ValueKind == JsonValueKind.Number && item.TryGetUInt64(out ulong bare)) + { + hulls.Add(new Link(bare, null)); + } + else if (item.ValueKind == JsonValueKind.Object && item.TryGetProperty("h", out JsonElement h) + && h.TryGetUInt64(out ulong hash)) + { + Vector3? at = null; + if (item.TryGetProperty("at", out JsonElement place) && place.ValueKind == JsonValueKind.Array + && place.GetArrayLength() == 3) + { + var point = new Vector3(place[0].GetSingle(), place[1].GetSingle(), place[2].GetSingle()); + if (float.IsFinite(point.X) && float.IsFinite(point.Y) && float.IsFinite(point.Z)) at = point; + } + hulls.Add(new Link(hash, at)); + } + } + if (hulls.Count > 0) links[entry.Name] = hulls; + } + } lock (Cache) Cache[path] = (stamp, links); return Copy(links); } - catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException) + catch (Exception ex) when (ex is IOException or JsonException or UnauthorizedAccessException + or FormatException or InvalidOperationException) { return new(); } } // Callers change what they are handed (Set, Rename); the kept one stays as the file is. - private static Dictionary> Copy(Dictionary> links) => + private static Dictionary> Copy(Dictionary> links) => links.ToDictionary(pair => pair.Key, pair => pair.Value.ToList()); } diff --git a/src/Illusion.Formats/Frames/Resources/FrameLOD.cs b/src/Illusion.Formats/Frames/Resources/FrameLOD.cs index 28e32ce..491a4f5 100644 --- a/src/Illusion.Formats/Frames/Resources/FrameLOD.cs +++ b/src/Illusion.Formats/Frames/Resources/FrameLOD.cs @@ -55,6 +55,19 @@ public FrameLOD(FrameLOD other) SplitCapsule = (byte[])other.SplitCapsule.Clone(); } + /// + /// Whether this level draws the way does, the buffers aside: the distance it is + /// drawn to, the vertex layout and count, and the two capsules byte for byte. Two levels on the same + /// buffers are not the same level for that alone — one of them may have been edited since. + /// + public bool DrawsLike(FrameLOD other) + { + ArgumentNullException.ThrowIfNull(other); + return Distance.Equals(other.Distance) && VertexDeclaration == other.VertexDeclaration && NumVerts == other.NumVerts + && NZero1 == other.NZero1 && OpcodeCapsule.AsSpan().SequenceEqual(other.OpcodeCapsule) + && SplitCapsule.AsSpan().SequenceEqual(other.SplitCapsule); + } + /// Fills this LOD from the wire fields and the two capsules handed over by the native /// boundary. internal void LoadFromWireParts(float distance, HashName indexBuffer, uint declaration, diff --git a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs index a966509..b1d40df 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs @@ -160,26 +160,79 @@ Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) Check("a drag still takes the hull along", ProbeAssert.Approx(hull.WorldTransform.Translation - dragFrom, new Vector3(0f, 12f, 0f), 0.01f)); + // ── a resize re-cooks the hull under a new hash: the record follows it, and comes back on undo ── + var placed = (CollisionInstanceAdapter)hulls[0].Source!; + ulong hashBefore = placed.Instance.Hash; + Vector3 about = moved.WorldTransform.Translation; + host.Selection.SetSelection([node], node); + host.GizmoBeginDrag(Rendering.Gizmos.GizmoMode.Scale); + host.GizmoApplyWorldDelta(Matrix4x4.CreateTranslation(-about) * Matrix4x4.CreateScale(1.5f) * Matrix4x4.CreateTranslation(about)); + host.GizmoEndDrag(); + Check("a resize gives the linked hull a new hash", placed.Instance.Hash != hashBefore, + $"{hashBefore:x16} → {placed.Instance.Hash:x16}"); + Check("and the object's record names the new one, so the hull is still the object's", + ImportLinks.HullsOf(dir, "probe_live") is [{ } now] && now.Hull == placed.Instance.Hash + && host.LinkedCollisionNodes(node).Count == 1); + host.Undo(); + Check("undoing the resize puts the hull and the record back", + placed.Instance.Hash == hashBefore && ImportLinks.HullsOf(dir, "probe_live") is [{ } then] && then.Hull == hashBefore + && host.LinkedCollisionNodes(node).Count == 1); + + // ── the same hull given twice, the second placement nine metres from the object's pivot ── + int given = 1; + host.Selection.SetSelection([hulls[0]], hulls[0]); + host.DuplicateSelected(); + SceneNode? twin = host.Selection.Selected.FirstOrDefault( + n => n.Source is CollisionInstanceAdapter && !ReferenceEquals(n, hulls[0])); + Check("a second placement of the same hull can be made", twin != null); + if (twin?.Source is CollisionInstanceAdapter farPlaced) + { + host.Selection.SetSelection([twin], twin); + host.GizmoBeginDrag(Rendering.Gizmos.GizmoMode.Move); + host.GizmoApplyWorldDelta(Matrix4x4.CreateTranslation(9f, 0f, 0f)); + host.GizmoEndDrag(); + var owner = ((FrameNodeAdapter)node.Source!).Frame; + // As an import records them: one entry per placement, each with its place in the object's space. + ImportLinks.Set(dir, "probe_live", [D3DImageHost.LinkFor(owner, placed.Instance), D3DImageHost.LinkFor(owner, farPlaced.Instance)]); + float away = Vector3.Distance(farPlaced.Instance.Position, moved.WorldTransform.Translation); + IReadOnlyList both = host.LinkedCollisionNodes(node); + Check("two placements of one hull are both the object's — one of them well past five metres from its pivot", + both.Count == 2 && both.Contains(twin) && both.Contains(hulls[0]) && away > 5f, + $"{both.Count} linked; the far one is {away:0.#} m from the pivot"); + given = 2; + Vector3 nearAt = placed.Instance.Position, farAt = farPlaced.Instance.Position; + before = moved.LocalTransform; + after = before; + after.Translation += new Vector3(0f, 0f, 20f); + moved.LocalTransform = after; + host.RecordTransform(node, before, after); + Check("and both go with the object when it is moved", + ProbeAssert.Approx(placed.Instance.Position - nearAt, new Vector3(0f, 0f, 20f), 0.01f) + && ProbeAssert.Approx(farPlaced.Instance.Position - farAt, new Vector3(0f, 0f, 20f), 0.01f) + && host.LinkedCollisionNodes(node).Count == 2); + } + // ── a rename takes the record along ── string? renamed = session.SetProperty("probe_live", "Base.Name", "probe_live_renamed"); Check("renaming the object moves its record to the new name", - renamed == null && ImportLinks.HullsOf(dir, "probe_live").Count == 0 && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 1, + renamed == null && ImportLinks.HullsOf(dir, "probe_live").Count == 0 && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == given, renamed ?? ""); - Check("and the hull is found under the new name", host.LinkedCollisionNodes(node).Count == 1); + Check("and the hull is found under the new name", host.LinkedCollisionNodes(node).Count == given); host.Undo(); Check("undoing the rename moves the record back", - ImportLinks.HullsOf(dir, "probe_live").Count == 1 && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 0); + ImportLinks.HullsOf(dir, "probe_live").Count == given && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 0); host.Redo(); // ── delete takes the hull; undo brings both back ── host.Selection.SetSelection([node], node); host.DeleteSelected(); - Check("deleting the object deletes its hull", + Check("deleting the object deletes every hull it was given", Nodes(host).Count(n => n.Source is CollisionInstanceAdapter) == hullsAtStart && !Nodes(host).Any(n => ReferenceEquals(n, node))); host.Undo(); - Check("and undo brings both back", - Nodes(host).Count(n => n.Source is CollisionInstanceAdapter) == hullsAtStart + 1 && host.LinkedCollisionNodes(node).Count == 1); + Check("and undo brings them all back", + Nodes(host).Count(n => n.Source is CollisionInstanceAdapter) == hullsAtStart + given + && host.LinkedCollisionNodes(node).Count == given); // ── everything undone: no object, no hull, no record ── for (int guard = 0; host.History.CanUndo && guard < 50; guard++) host.Undo(); diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 1100c92..f78169e 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -449,6 +449,21 @@ static List Drawn(TransplantedObject t) => firstDeleted.Reattach(); two.Reattach(); } + + // ── A block edited since it was copied is not the source's block any more ── + // The first copy's draw distance is changed, as a user would to hide a level; the next import of + // the same object must not inherit that — it draws from the same buffers through a block of its own. + Formats.Frames.Resources.FrameGeometry edited = Drawn(one)[0].Geometry; + float distanceWas = edited.LOD[0].Distance; + edited.LOD[0].Distance = distanceWas + 123f; + TransplantedObject? afterEdit = FrameTransplant.TryTransplant(document, theirs, propRoot, "probe_share_edited", + FrameTransplant.Standing.Prototype, Matrix4x4.Identity, shared, out reason); + Check("a copy whose draw distance was changed does not lend its block: the next import gets the source's, on the same buffers", + afterEdit != null && afterEdit.VertexBuffers.Count == 0 && afterEdit.Geometries.Count > 0 + && Drawn(afterEdit).All(m => !ReferenceEquals(m.Geometry, edited) && m.Geometry.LOD[0].Distance != distanceWas + 123f), + afterEdit == null ? reason ?? "" : $"{afterEdit.Geometries.Count} block(s) of its own, {afterEdit.VertexBuffers.Count} buffers copied"); + afterEdit?.Detach(); + edited.LOD[0].Distance = distanceWas; two.Detach(); Check("taking the copies out leaves the first one's geometry in place", one.VertexBuffers.All(b => ours.VertexBuffers.GetBuffer(b.Hash) != null) diff --git a/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs b/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs index fd87a05..2865ce0 100644 --- a/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs +++ b/src/Illusion/Diagnostics/Probes/PropCatalogProbes.cs @@ -79,6 +79,38 @@ void Check(string name, bool ok, string detail = "") try { quiet = renderer.Draw(renderer.Stage(gone)) == null; } catch (Exception) { quiet = false; } Check("a prop whose archive cannot be read gets no picture and throws nothing", quiet); + + // The tab itself, at the size it has in the editor: cards are made a page at a time, not for the + // whole catalog at once (a wrapping panel cannot virtualize, and 2,700 cards took two seconds). + var tab = new global::Illusion.Views.PropsTabView(); + var window = new System.Windows.Window + { + Width = 330, Height = 700, Left = -4000, Top = 0, ShowInTaskbar = false, ShowActivated = false, + WindowStyle = System.Windows.WindowStyle.None, Content = tab, + }; + window.Show(); + DateTime until = DateTime.UtcNow.AddSeconds(120); + while (tab.Tiles.Items.Count == 0 && DateTime.UtcNow < until) Pump(200); + Pump(1500); + int firstPage = tab.Tiles.Items.Count; + Check("the tab makes cards for a page of the catalog, not for all of it", + firstPage > 0 && firstPage < entries.Count && firstPage <= 400, $"{firstPage} cards for {entries.Count} objects"); + var scroller = Descendant(tab.Tiles); + scroller?.ScrollToVerticalOffset(scroller.ExtentHeight); // the thumb dragged to the bottom + Pump(1500); + int afterScroll = tab.Tiles.Items.Count; + Check("scrolling to the end of what is shown brings the next page, and only that", + scroller != null && afterScroll > firstPage && afterScroll <= firstPage * 3, $"{firstPage} → {afterScroll}"); + int withPicture = tab.Tiles.Items.OfType().Count(t => t.Thumbnail != null); + window.Hide(); + Pump(400); + window.Show(); + Pump(6000); + int afterReopen = tab.Tiles.Items.OfType().Count(t => t.Thumbnail != null); + Check("hidden and shown again, the tab goes on filling in pictures", + afterReopen > withPicture || tab.Tiles.Items.OfType().All(t => t.Thumbnail != null || t.ThumbnailTried), + $"{withPicture} → {afterReopen} of {afterScroll}"); + window.Close(); } catch (Exception ex) { @@ -92,6 +124,28 @@ void Check(string name, bool ok, string detail = "") } } + private static void Pump(int ms) + { + DateTime end = DateTime.UtcNow.AddMilliseconds(ms); + while (DateTime.UtcNow < end) + { + System.Windows.Threading.Dispatcher.CurrentDispatcher.Invoke(() => { }, System.Windows.Threading.DispatcherPriority.Background); + Thread.Sleep(10); + } + } + + private static T? Descendant(System.Windows.DependencyObject root) where T : System.Windows.DependencyObject + { + int n = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < n; i++) + { + System.Windows.DependencyObject child = System.Windows.Media.VisualTreeHelper.GetChild(root, i); + if (child is T hit) return hit; + if (Descendant(child) is { } deeper) return deeper; + } + return null; + } + // A tile with nothing drawn on it is one flat colour. private static bool HasDetail(BitmapSource picture) { diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index 5eeb026..f5be3a0 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -295,9 +295,10 @@ public void DeleteSelected() /// /// The collision placements a piece of scenery this toolkit carried in was given (): - /// for each recorded hull, the placement of it nearest the object, within a few metres of where the object - /// IS — its box, not its pivot. Empty for everything else — a stock object's collision is not tied to it by - /// anything, and guessing by position would take a building's hull along with a bench. + /// for each recorded placement, the one of that hull standing where the object's matrix now puts the place + /// it was recorded at (a record without a place: the one nearest the object's box). Empty for everything + /// else — a stock object's collision is not tied to it by anything, and guessing by position would take a + /// building's hull along with a bench. /// /// The world matrix to look from instead of the node's own — for a caller that /// has already moved the object and wants the hulls it had where it stood before. @@ -312,9 +313,15 @@ internal IReadOnlyList LinkedCollisionNodes(SceneNode node, Matrix4x4 /// Only nodes that have hulls are in the result. /// internal Dictionary> LinkedCollision(IEnumerable nodes, + IReadOnlyDictionary? standingAt = null) => + LinkedSlots(nodes, standingAt).ToDictionary(pair => pair.Key, pair => pair.Value.Select(slot => slot.Hull).ToList()); + + /// The same, with which entry of the object's record each placement answers to — for a caller + /// that has to rewrite the record (a scale re-cooks a hull under a new hash). + internal Dictionary> LinkedSlots(IEnumerable nodes, IReadOnlyDictionary? standingAt = null) { - var result = new Dictionary>(); + var result = new Dictionary>(); var asked = new List<(SceneNode Node, FrameNodeAdapter Frame, SceneDocumentAdapter Scene, SceneNode Document)>(); foreach (SceneNode node in nodes) { @@ -328,7 +335,7 @@ internal Dictionary> LinkedCollision(IEnumerable> links = Assets.Sds.ImportLinks.All(dir); + IReadOnlyDictionary> links = Assets.Sds.ImportLinks.All(dir); if (links.Count == 0 || FindCollisionLayer(archive.First().Document) is not { } layer) continue; // The object a recorded name belongs to: the first of that name, as the link was made for. @@ -356,28 +363,43 @@ internal Dictionary> LinkedCollision(IEnumerable(); - foreach ((string linked, List hulls) in links) + foreach ((string linked, List hulls) in links) { if (!owners.TryGetValue(linked, out Formats.Frames.ObjectTypes.FrameObjectBase? owner)) continue; - List<(Matrix4x4 World, Matrix4x4 ToLocal, Vector3 Min, Vector3 Max)> boxes = - BoxesOf(owner, looksFrom.TryGetValue(owner, out Matrix4x4 from) ? from : null, out Vector3 pivot); + Matrix4x4? from = looksFrom.TryGetValue(owner, out Matrix4x4 lookedFrom) ? lookedFrom : null; + Matrix4x4 standing = from ?? owner.WorldTransform; + List<(Matrix4x4 World, Matrix4x4 ToLocal, Vector3 Min, Vector3 Max)>? boxes = null; + Vector3 pivot = standing.Translation; for (int slot = 0; slot < hulls.Count; slot++) { - if (!byHull.TryGetValue(hulls[slot], out List? candidates)) continue; + if (!byHull.TryGetValue(hulls[slot].Hull, out List? candidates)) continue; foreach (int i in candidates) { Vector3 at = ((CollisionInstanceAdapter)placements[i].Source!).Instance.Position; - // To the object's box: a district mesh's pivot can be metres from its geometry, and - // measured from there its own hulls were "too far" and never linked. The pivot still - // counts — nothing that was linked before stops being linked. - float distance = Vector3.Distance(pivot, at); - foreach ((Matrix4x4 world, Matrix4x4 toLocal, Vector3 min, Vector3 max) in boxes) + float distance; + if (hulls[slot].At is { } place) + { + // Where the object's matrix puts the place the placement was recorded at. The two + // move, turn and resize together, so this is where it stands — however far that + // is from the object's pivot or its box. The few metres are for a hull the user + // has since nudged on its own. + distance = Vector3.Distance(Vector3.Transform(place, standing), at); + } + else { - Vector3 nearest = Vector3.Transform(Vector3.Clamp(Vector3.Transform(at, toLocal), min, max), world); - distance = MathF.Min(distance, Vector3.Distance(nearest, at)); + // A record from before places were kept: by nearness to the object's box (its + // pivot still counts — nothing that was linked before stops being linked). + boxes ??= BoxesOf(owner, from, out pivot); + distance = Vector3.Distance(pivot, at); + foreach ((Matrix4x4 world, Matrix4x4 toLocal, Vector3 min, Vector3 max) in boxes) + { + Vector3 nearest = Vector3.Transform(Vector3.Clamp(Vector3.Transform(at, toLocal), min, max), world); + distance = MathF.Min(distance, Vector3.Distance(nearest, at)); + } } if (distance < 5f) bids.Add((distance, linked, slot, i)); } @@ -386,22 +408,22 @@ internal Dictionary> LinkedCollision(IEnumerable(); var filledSlots = new HashSet<(string, int)>(); - var found = new Dictionary>(StringComparer.Ordinal); + var found = new Dictionary>(StringComparer.Ordinal); foreach ((_, string linked, int slot, int placement) in bids .OrderBy(b => b.Distance).ThenBy(b => b.Object, StringComparer.Ordinal).ThenBy(b => b.Placement)) { if (takenPlacements.Contains(placement) || filledSlots.Contains((linked, slot))) continue; takenPlacements.Add(placement); filledSlots.Add((linked, slot)); - if (!found.TryGetValue(linked, out List? list)) found[linked] = list = []; - list.Add(placements[placement]); + if (!found.TryGetValue(linked, out List<(int Slot, SceneNode Hull)>? list)) found[linked] = list = []; + list.Add((slot, placements[placement])); } foreach (var a in archive) { string name = a.Frame.Frame.Name.String; if (name is { Length: > 0 } && owners.TryGetValue(name, out Formats.Frames.ObjectTypes.FrameObjectBase? owner) - && ReferenceEquals(owner, a.Frame.Frame) && found.TryGetValue(name, out List? hulls)) + && ReferenceEquals(owner, a.Frame.Frame) && found.TryGetValue(name, out List<(int Slot, SceneNode Hull)>? hulls)) { result[a.Node] = hulls; } @@ -410,6 +432,14 @@ internal Dictionary> LinkedCollision(IEnumerableThe record of one placement given to : its hull, and where it stands + /// in the object's own space right now. + internal static Assets.Sds.ImportLinks.Link LinkFor(Formats.Frames.ObjectTypes.FrameObjectBase owner, + Formats.Collisions.CollisionInstance placement) => + new(placement.Hash, Matrix4x4.Invert(owner.WorldTransform, out Matrix4x4 toOwner) + ? Vector3.Transform(placement.Position, toOwner) + : null); + // Where an object is, for the question "is this placement beside it": the stored box of every mesh under // it, each with its world matrix and that matrix's inverse. Read from the frames — no geometry is decoded. // With the whole subtree is taken as standing where that matrix puts the root. @@ -481,11 +511,11 @@ private void DuplicateLinkedCollision(IReadOnlyList selected) { continue; } - var hashes = new List(); + var hashes = new List(); foreach (SceneNode hull in given.TryGetValue(source, out List? hulls) ? hulls : []) { if (hull.Source is not CollisionInstanceAdapter original) continue; - hashes.Add(original.Instance.Hash); + hashes.Add(LinkFor(copyFrame.Frame, original.Instance)); if (selected.Contains(hull)) continue; var placement = new Formats.Collisions.CollisionInstance { diff --git a/src/Illusion/Viewport/ImportLinkEdit.cs b/src/Illusion/Viewport/ImportLinkEdit.cs index a462835..9809e61 100644 --- a/src/Illusion/Viewport/ImportLinkEdit.cs +++ b/src/Illusion/Viewport/ImportLinkEdit.cs @@ -9,9 +9,9 @@ namespace Illusion.Viewport; /// scene, so nothing takes it back unless the edit that made it does: written bare, an import or a duplicate /// that was undone left a name tied to hulls for good, waiting for the next object to be given that name. /// -internal sealed class ImportLinkEdit(string extractedDir, string frameName, IReadOnlyList hulls) : IEditAction +internal sealed class ImportLinkEdit(string extractedDir, string frameName, IReadOnlyList hulls) : IEditAction { - private readonly List _before = [.. ImportLinks.HullsOf(extractedDir, frameName)]; + private readonly List _before = [.. ImportLinks.HullsOf(extractedDir, frameName)]; public void Redo() => ImportLinks.Set(extractedDir, frameName, hulls); diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index e1bdbf7..471c3ad 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -442,7 +442,7 @@ Assets.Sds.ArchiveCarry.Report Carry(Assets.Frames.FrameTransplant.TransplantedO if (hulls.Count > 0) { edits.Add(new ImportLinkEdit(Assets.MafiaEnvironment.ExtractedDir(destination), carried.Root.Name.String, - [.. hulls.Select(h => h.Placement.Hash)])); + [.. hulls.Select(h => D3DImageHost.LinkFor(carried.Root, h.Placement))])); } // "Its own" is a guess by position: nothing in an archive ties a static object to its collision, so // what is taken is every hull standing inside the object's box. For a shelf that includes what stood diff --git a/src/Illusion/Viewport/TransformEditController.cs b/src/Illusion/Viewport/TransformEditController.cs index ee29710..3bc9945 100644 --- a/src/Illusion/Viewport/TransformEditController.cs +++ b/src/Illusion/Viewport/TransformEditController.cs @@ -77,15 +77,48 @@ public void GizmoBeginDrag(GizmoMode mode) // An object carried in from another archive drags the collision it was given along with it: nothing in // the file ties the two, so without this the hull would stay where the object used to stand. var dragged = new HashSet(_dragGroup.Select(g => g.Node)); - foreach (List hulls in _host.LinkedCollision(dragged.ToList()).Values) + _dragLinks = _host.LinkedSlots(dragged.ToList()); + foreach (List<(int Slot, SceneNode Hull)> hulls in _dragLinks.Values) { - foreach (SceneNode hull in hulls) + foreach ((_, SceneNode hull) in hulls) { if (hull.Source is IFrameNode h && dragged.Add(hull)) _dragGroup.Add((hull, h.WorldTransform, h.LocalTransform)); } } } + // Which linked placements the drag took along, and for which entry of each object's record. + private Dictionary> _dragLinks = new(); + + // A resize ends with the dragged placements re-pointed at freshly cooked hulls — under new hashes. The + // record of what an object was given names the old ones, and unless it is rewritten here the object has + // lost its collision by the next operation: nothing answers to the old hash any more. Rewritten as an + // edit, so that undoing the resize puts the record back with the hulls. + private List RelinkAfterMint(Dictionary> links) + { + var edits = new List(); + foreach ((SceneNode node, List<(int Slot, SceneNode Hull)> slots) in links) + { + if (node.Source is not FrameNodeAdapter frame) continue; + string dir = Assets.MafiaEnvironment.ExtractedDir(frame.Document.SourceArchive); + string name = frame.Frame.Name.String; + List record = [.. Assets.Sds.ImportLinks.HullsOf(dir, name)]; + bool changed = false; + foreach ((int slot, SceneNode hull) in slots) + { + if (slot >= record.Count || hull.Source is not CollisionInstanceAdapter placed) continue; + if (record[slot].Hull == placed.Instance.Hash) continue; + record[slot] = record[slot] with { Hull = placed.Instance.Hash }; + changed = true; + } + if (!changed) continue; + var edit = new ImportLinkEdit(dir, name, record); + edit.Redo(); + edits.Add(edit); + } + return edits; + } + // True if any frame-graph ancestor of fn is itself selected (its cascade will move fn). private static bool HasSelectedAncestor(IFrameNode fn, HashSet selected) { @@ -134,7 +167,11 @@ _baselineNode is { } node /// Abandons the drag: drops the snapshots without recording anything. The caller has already put /// the objects back by applying an identity delta — which also unwinds a collision placement's previewed /// scale, so there is nothing left to mint either. - public void GizmoCancelDrag() => _dragGroup.Clear(); + public void GizmoCancelDrag() + { + _dragGroup.Clear(); + _dragLinks = new(); + } /// Ends the drag: pushes the whole group move as ONE undoable edit. public void GizmoEndDrag() @@ -146,7 +183,11 @@ public void GizmoEndDrag() // has and the hull would grow on every cycle. var nodes = new List(_dragGroup.Count); foreach ((SceneNode node, _, _) in _dragGroup) nodes.Add(node); - IReadOnlyList mints = _host.CollisionEditing.MintPreviewedScales(nodes); + List mints = [.. _host.CollisionEditing.MintPreviewedScales(nodes)]; + // After the mints in the composite, so before them on undo: the record goes back to the old hashes + // while the placements still carry the new ones, and then the placements follow. + if (mints.Count > 0) mints.AddRange(RelinkAfterMint(_dragLinks)); + _dragLinks = new(); var items = new List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)>(_dragGroup.Count); foreach ((SceneNode node, _, Matrix4x4 beforeLocal) in _dragGroup) @@ -162,7 +203,7 @@ public void RecordTransform(SceneNode node, Matrix4x4 before, Matrix4x4 after) { if (before == after) return; var items = new List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> { (node, before, after) }; - IReadOnlyList mints = []; + List mints = []; // A number typed into the Transform panel comes through here, not through a drag — and the collision // an imported object was given has to follow it all the same. It used to stay where the object had @@ -177,7 +218,9 @@ public void RecordTransform(SceneNode node, Matrix4x4 before, Matrix4x4 after) { Matrix4x4 delta = back * (after * parent); var hulls = new List<(SceneNode Node, Matrix4x4 Before)>(); - foreach (SceneNode hull in _host.LinkedCollisionNodes(node, worldBefore)) + Dictionary> links = + _host.LinkedSlots([node], new Dictionary { [node] = worldBefore }); + foreach ((_, SceneNode hull) in links.TryGetValue(node, out List<(int Slot, SceneNode Hull)>? mine) ? mine : []) { if (hull.Source is not IFrameNode h) continue; hulls.Add((hull, h.LocalTransform)); @@ -187,7 +230,8 @@ public void RecordTransform(SceneNode node, Matrix4x4 before, Matrix4x4 after) if (hulls.Count > 0) { // A placement cannot store a scale: a resize becomes a rescaled hull, as at the end of a drag. - mints = _host.CollisionEditing.MintPreviewedScales(hulls.Select(x => x.Node).ToList()); + mints = [.. _host.CollisionEditing.MintPreviewedScales(hulls.Select(x => x.Node).ToList())]; + if (mints.Count > 0) mints.AddRange(RelinkAfterMint(links)); foreach ((SceneNode hull, Matrix4x4 hullBefore) in hulls) { items.Add((hull, hullBefore, ((IFrameNode)hull.Source!).LocalTransform)); diff --git a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml index 16fde54..485c19c 100644 --- a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml +++ b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml @@ -60,6 +60,7 @@ diff --git a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs index e526305..3da39e4 100644 --- a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs +++ b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs @@ -27,6 +27,15 @@ public partial class PropsTabView : UserControl private const string AllShelves = "All"; private readonly List _all = []; + + // A wrapping panel cannot virtualize, and the catalog is some 2,700 objects: every one of them given a + // card took the UI thread two seconds each time the tab opened or the filter changed, for a pane that + // shows a dozen. So the list the cards are made from is a PAGE of what the filter lets through, and grows + // by another page when the end of it is scrolled into reach. + private const int PageSize = 120; + private List _filtered = []; + private readonly System.Collections.ObjectModel.ObservableCollection _paged = []; + private int _firstInView; private readonly PropThumbnailRenderer _thumbnails = new(); private readonly DispatcherTimer _pictures; // The one prop whose archive is being read, on a pool thread, and the tile it is for. @@ -59,10 +68,19 @@ public PropsTabView() _pictures.Stop(); } }; + Tiles.ItemsSource = _paged; IsVisibleChanged += (_, _) => { - if (IsVisible) LoadCatalog(force: false); - else _pictures.Stop(); + if (!IsVisible) + { + _pictures.Stop(); + return; + } + LoadCatalog(force: false); + // The timer was stopped when the tab was hidden, and a catalog that is already loaded has nothing + // to restart it: cards whose pictures were still to come kept their placeholders until a filter + // was touched. + if (_loaded && _paged.Count > 0) _pictures.Start(); }; Unloaded += (_, _) => _thumbnails.Dispose(); } @@ -116,7 +134,10 @@ private void ApplyFilter() || t.Entry.Archive.Contains(query, StringComparison.OrdinalIgnoreCase) || t.Entry.Category.Contains(query, StringComparison.OrdinalIgnoreCase))) .ToList(); - Tiles.ItemsSource = shown; + _filtered = shown; + _paged.Clear(); + _firstInView = 0; + ShowAnotherPage(); if (_loaded) { Status.Text = $"{shown.Count} of {_all.Count} objects · drag one onto the viewport, or double-click it"; @@ -124,6 +145,33 @@ private void ApplyFilter() if (shown.Count > 0) _pictures.Start(); } + private void ShowAnotherPage() + { + int upTo = Math.Min(_filtered.Count, _paged.Count + PageSize); + for (int i = _paged.Count; i < upTo; i++) _paged.Add(_filtered[i]); + } + + // The end of what is shown has come within a screen of the viewport (or everything shown fits in it): the + // next page. Each page changes the extent, which raises this again, so a tall pane fills in a few steps. + private void Tiles_ScrollChanged(object sender, ScrollChangedEventArgs e) + { + if (_paged.Count > 0 && e.ExtentHeight > 0) + { + _firstInView = Math.Clamp((int)(e.VerticalOffset / e.ExtentHeight * _paged.Count), 0, _paged.Count - 1); + } + if (_paged.Count >= _filtered.Count || e.VerticalOffset + (2 * e.ViewportHeight) < e.ExtentHeight) return; + // A jump to the very end (the End key) keeps the view pinned there while the list grows under it, and + // would walk through every page in one go — the two seconds this paging exists to avoid. One page per + // gesture, unless what is shown does not even fill the pane. + bool fills = e.ExtentHeight > e.ViewportHeight; + if (fills && (DateTime.UtcNow - _lastPage).TotalMilliseconds < 200) return; + _lastPage = DateTime.UtcNow; + ShowAnotherPage(); + _pictures.Start(); + } + + private DateTime _lastPage; + // One picture per tick: a kept one if any tile shown still lacks it, otherwise one drawn — taking the // archive the renderer already holds first, so archives are read once rather than once per tile. The // reading happens on a pool thread; the tick only starts it and, once it is done, draws. @@ -136,13 +184,16 @@ private void DrawNextPicture() readFor.Thumbnail = read.IsCompletedSuccessfully ? _thumbnails.Draw(read.Result) : null; return; } - if (Tiles.ItemsSource is not List shown || !IsVisible) + if (!IsVisible) { _pictures.Stop(); return; } if (_reading != null) return; - List missing = shown.Where(t => t.Thumbnail == null && !t.ThumbnailTried).ToList(); + // From the cards in view onward first, then the ones above them: what is looked at is drawn before + // what was scrolled past. + List missing = [.. _paged.Skip(_firstInView).Concat(_paged.Take(_firstInView)) + .Where(t => t.Thumbnail == null && !t.ThumbnailTried)]; if (missing.Count == 0) { _thumbnails.Rest(); @@ -160,7 +211,8 @@ private void DrawNextPicture() } } - PropTileViewModel next = missing.OrderBy(t => t.Entry.Archive, StringComparer.OrdinalIgnoreCase).First(); + // Among the first couple of screens of those, by archive — an archive is read once for all its props. + PropTileViewModel next = missing.Take(48).OrderBy(t => t.Entry.Archive, StringComparer.OrdinalIgnoreCase).First(); next.ThumbnailTried = true; _readingFor = next; _reading = Task.Run(() => _thumbnails.Stage(next.Entry)); From 1fc6c8cfb5116af29f6ed8bb65be5189ddc5e646 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:44:54 +0300 Subject: [PATCH 054/102] fix(cars): review of #7 - the scratch clone takes itself back, manifests that do not read, undo re-enlists the archive What the review of this branch found that c4b33d4 and 64b4e9b had not already answered (the rollback of Clone and its packs, the stale catalog behind resource_open, the modal dialog on the tool path and the non-finite tuning numbers are in those). CloneExtracted is whole or nothing by itself. The rollback lived in Clone; the clone on working copies, called on its own, still wrote the vehicle row before the title, the paint row and the traffic slots, and a failure at any of them left a car registered by half and its name taken. It now keeps the journal itself (Clone passes its own in, to add the packs to it). The journal no longer tries to put back a file that was noted and never written - with the text table held open, that was the one thing the undo reported it could not restore. The M2O export does not write over a manifest it cannot read. An existing vehicles.json or package.json that does not parse was taken for 'none yet' and replaced with this one car - after the archives had been copied. Both are read before anything is written; one that is there and does not read is a refusal that leaves the folder as it was. Undo and redo of a value that lives in the working copy put the archive back on the build list (WorkingCopyEdit, for tuning and effect edits, in the panel and in car_tuning_set). A Build takes the archive off the list; an undo after it changed the working copy and the next Build packed nothing, leaving the game with the value that had been undone. --probe-car-clone 77/77 (a clone whose text table is held open is refused and leaves the vehicle table, the ingame tables, the text and the folders as they were; the same clone then succeeds). --probe-car-m2o 24/24 (a vehicles.json, then a package.json, that does not read: refused, both files and the folder untouched). --probe-car-clone-rollback 11/11. Live, on a probe copy of jeep.sds: set Mass, build, undo - the archive is pending again; redo - still pending. --- src/Illusion.Assets/Cars/CarCloner.cs | 48 ++++++++++++---- src/Illusion.Assets/Cars/CarM2oExport.cs | 56 +++++++++++++------ src/Illusion.Assets/Cars/GameWriteJournal.cs | 20 ++++++- .../Diagnostics/Probes/CarCloneProbes.cs | 25 +++++++++ .../Diagnostics/Probes/CarM2oExportProbes.cs | 17 ++++++ src/Illusion/Mcp/AppEditorSession.cs | 2 +- src/Illusion/Viewport/WorkingCopyEdit.cs | 27 +++++++++ .../Views/ScenePanel/ScenePanel.xaml.cs | 4 +- 8 files changed, 166 insertions(+), 33 deletions(-) create mode 100644 src/Illusion/Viewport/WorkingCopyEdit.cs diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs index d70afcd..1115e91 100644 --- a/src/Illusion.Assets/Cars/CarCloner.cs +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -144,23 +144,13 @@ [.. cars.Select(c => (SdsMeshLoader.EnsureExtracted(c.From), MafiaEnvironment.Ex // tables written and not packed, is a state no step of this intended, and one that used to make the // retry impossible ("already exists"). var journal = new GameWriteJournal(); - journal.Remember(Path.Combine(folders.Tables, "tables", "vehicles.tbl")); - foreach (string text in folders.Text) journal.Remember(Path.Combine(text, "tables", "TextDatabase.dat")); - string ingameTables = Path.Combine(folders.Ingame, "tables"); - if (Directory.Exists(ingameTables)) - { - foreach (string table in Directory.GetFiles(ingameTables, "*.tbl")) journal.Remember(table); - } - journal.Remember(Path.Combine(folders.Ingame, "SDSContent.xml")); - journal.RememberContents(Path.Combine(ingameTables, "AiProps")); - foreach ((string _, string to) in folders.Cars) journal.WillCreateFolder(to); CarCloneResult? result; var packed = new List<(string Archive, string? Backup)>(); var droppedByArchive = new List<(string Archive, IReadOnlyList Dropped)>(); try { - result = CloneExtracted(folders, stem, name, traffic, title, out refusal); + result = CloneExtracted(folders, stem, name, traffic, title, journal, out refusal); if (result == null) return null; // refused before writing anything DateTime when = DateTime.Now; @@ -231,6 +221,29 @@ private static string TakenBack(string what, Exception ex, List problems /// public static CarCloneResult? CloneExtracted( CarCloneFolders folders, string source, string name, bool traffic, string? title, out string? refusal) + { + // Whole or not at all, here too and not only in Clone: the vehicle row is written before the title, + // the paint row and the traffic slots, and a failure at any of those used to leave a car registered + // with half of what registers it — and its name taken, so that the clone could not be tried again. + var journal = new GameWriteJournal(); + try + { + CarCloneResult? result = CloneExtracted(folders, source, name, traffic, title, journal, out refusal); + if (result != null) journal.Commit(); + return result; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + refusal = TakenBack("the clone", ex, journal.Undo()); + return null; + } + } + + // The same, noting in every file it is about to write and every folder it is + // about to make, and leaving the taking back to the caller — who may have more to take back than this. + private static CarCloneResult? CloneExtracted( + CarCloneFolders folders, string source, string name, bool traffic, string? title, GameWriteJournal journal, + out string? refusal) { ArgumentNullException.ThrowIfNull(folders); refusal = Refuse(name); @@ -280,6 +293,19 @@ private static string TakenBack(string what, Exception ex, List problems for (int i = 0; i < vehicles.RowCount; i++) id = Math.Max(id, (int)vehicles.Cell(i, VehicleIdColumn) + 1); var notes = new List(); + // Everything refused above was refused before a byte was written. From here on files change, and + // each is noted first. + journal.Remember(vehiclesPath); + foreach (string text in folders.Text) journal.Remember(Path.Combine(text, "tables", "TextDatabase.dat")); + string ingameTables = Path.Combine(folders.Ingame, "tables"); + if (Directory.Exists(ingameTables)) + { + foreach (string table in Directory.GetFiles(ingameTables, "*.tbl")) journal.Remember(table); + } + journal.Remember(Path.Combine(folders.Ingame, "SDSContent.xml")); + journal.RememberContents(Path.Combine(ingameTables, "AiProps")); + foreach ((string _, string to) in folders.Cars) journal.WillCreateFolder(to); + // The archives first: they are new folders, so a failure there leaves the tables as they were. foreach ((string from, string to) in folders.Cars) { diff --git a/src/Illusion.Assets/Cars/CarM2oExport.cs b/src/Illusion.Assets/Cars/CarM2oExport.cs index a2c1bdc..0eda749 100644 --- a/src/Illusion.Assets/Cars/CarM2oExport.cs +++ b/src/Illusion.Assets/Cars/CarM2oExport.cs @@ -133,6 +133,20 @@ public static string DefaultResource(string car) => return null; } string vehiclesFile = Path.Combine(output, VehiclesFile); + // Both manifests are read BEFORE anything is copied. One that is there and does not read — a typo, a + // file cut short — used to be taken for "no list yet" and written over with this one car: every + // earlier registration, or the package's own settings, gone, after the archives had already been + // replaced. It is a refusal, and the folder is left as it was. + if (Unreadable(vehiclesFile, node => node?["vehicles"] is JsonArray, "a list of vehicles") is { } badList) + { + refusal = badList; + return null; + } + if (Unreadable(Path.Combine(output, PackageFile), node => node is JsonObject, "a package manifest") is { } badPackage) + { + refusal = badPackage; + return null; + } if (Directory.Exists(output) && Directory.EnumerateFileSystemEntries(output).Any() && !File.Exists(vehiclesFile)) { refusal = $"{output} holds something that is not a car export — pick an empty folder"; @@ -296,22 +310,33 @@ private static JsonObject RowJson(GameTable table, int row, int? id) return json; } - // The cars a folder already lists; a file that does not read as one starts the list over. - private static JsonArray ReadList(string vehiclesFile) + // Why an existing manifest cannot be built on, or null when it is absent or reads as what it should be. + private static string? Unreadable(string path, Func isWhatItShouldBe, string what) { - if (!File.Exists(vehiclesFile)) return []; + if (!File.Exists(path)) return null; + string name = Path.GetFileName(path); try { - if (JsonNode.Parse(File.ReadAllText(vehiclesFile))?["vehicles"] is JsonArray existing) - { - return [.. existing.Select(v => v?.DeepClone())]; - } + return isWhatItShouldBe(JsonNode.Parse(File.ReadAllText(path))) + ? null + : $"{name} in the output folder is not {what} — it is left as it is; fix or remove it and export again"; } - catch (JsonException) + catch (Exception ex) when (ex is JsonException or IOException or UnauthorizedAccessException) { - // not a list — replaced + return $"{name} in the output folder cannot be read ({ex.Message}) — it is left as it is; fix or remove it and export again"; } - return []; + } + + // The cars a folder already lists. ExportFrom has checked that the file reads; one that stopped reading + // since is an error, not an empty list. + private static JsonArray ReadList(string vehiclesFile) + { + if (!File.Exists(vehiclesFile)) return []; + if (JsonNode.Parse(File.ReadAllText(vehiclesFile))?["vehicles"] is JsonArray existing) + { + return [.. existing.Select(v => v?.DeepClone())]; + } + throw new InvalidDataException($"{Path.GetFileName(vehiclesFile)} is not a list of vehicles"); } // A package.json somebody already wrote keeps what it says; it only has to ship the cars. @@ -320,14 +345,9 @@ private static void WritePackage(string packageFile, string resource, string mod JsonObject? package = null; if (File.Exists(packageFile)) { - try - { - package = JsonNode.Parse(File.ReadAllText(packageFile)) as JsonObject; - } - catch (JsonException) - { - // not a manifest — replaced - } + // Checked readable by ExportFrom before anything was written; never replaced for not reading. + package = JsonNode.Parse(File.ReadAllText(packageFile)) as JsonObject + ?? throw new InvalidDataException($"{Path.GetFileName(packageFile)} is not a package manifest"); } package ??= new JsonObject { diff --git a/src/Illusion.Assets/Cars/GameWriteJournal.cs b/src/Illusion.Assets/Cars/GameWriteJournal.cs index 2d440f0..d060846 100644 --- a/src/Illusion.Assets/Cars/GameWriteJournal.cs +++ b/src/Illusion.Assets/Cars/GameWriteJournal.cs @@ -144,12 +144,30 @@ void Try(string what, Action action) Try($"restoring {Path.GetFileName(path)}", () => { if (bytes == null) File.Delete(path); - else AtomicFile.WriteAllBytes(path, bytes); + // A file that was noted and never got written — often the very one whose write failed, still + // held by whatever made it fail — is as it was already: nothing to put back, nothing to report. + else if (!Unchanged(path, bytes)) AtomicFile.WriteAllBytes(path, bytes); }); } return problems; } + private static bool Unchanged(string path, byte[] bytes) + { + try + { + using var stream = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete); + if (stream.Length != bytes.Length) return false; + var now = new byte[bytes.Length]; + stream.ReadExactly(now); + return now.AsSpan().SequenceEqual(bytes); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return false; + } + } + // Only a FILE of that name is the pack's leftover. Whatever else stands there — the very thing that made // the pack fail, perhaps — is not this operation's to remove, and not being able to is no failure of the undo. private static void DeleteTemporary(string archive) diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index 3d462bf..8878083 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -211,6 +211,31 @@ void Check(string name, bool ok, string detail = "") Check("refuses a title that is not one short line", CarCloner.CloneExtracted(folders, Source, Name, traffic: true, "two\nlines", out string? badTitle) == null && badTitle != null, badTitle ?? ""); string[] textBefore = [.. text.Select(t => File.ReadAllText(Path.Combine(t, "tables", "TextDatabase.dat")))]; + + // A clone that fails half-way: the text table held open, so the title cannot be written — after + // the vehicle row has been. Nothing of it may stay, and the same clone has to be possible again. + if (text.Count > 0) + { + byte[] vehiclesBefore = File.ReadAllBytes(vehiclesPath); + string[] ingameBefore = [.. Directory.GetFiles(Path.Combine(ingame, "tables"), "*.tbl").Order() + .Select(f => Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(File.ReadAllBytes(f))))]; + string? halfWay; + using (new FileStream(Path.Combine(text[0], "tables", "TextDatabase.dat"), FileMode.Open, FileAccess.Read, FileShare.Read)) + { + Check("a clone whose title cannot be written is refused", + CarCloner.CloneExtracted(folders, Source, Name, traffic: true, Title, out halfWay) == null && halfWay != null, + halfWay ?? "cloned"); + } + string[] ingameAfter = [.. Directory.GetFiles(Path.Combine(ingame, "tables"), "*.tbl").Order() + .Select(f => Convert.ToHexString(System.Security.Cryptography.SHA1.HashData(File.ReadAllBytes(f))))]; + Check("and leaves nothing: the vehicle table, the ingame tables and the text as they were, no folder for the car", + File.ReadAllBytes(vehiclesPath).AsSpan().SequenceEqual(vehiclesBefore) && ingameAfter.SequenceEqual(ingameBefore) + && text.Select(t => File.ReadAllText(Path.Combine(t, "tables", "TextDatabase.dat"))).SequenceEqual(textBefore) + && cars.All(c => !Directory.Exists(c.To)), + $"vehicles.tbl {(File.ReadAllBytes(vehiclesPath).AsSpan().SequenceEqual(vehiclesBefore) ? "same" : "CHANGED")}, " + + $"car folder {(cars.Any(c => Directory.Exists(c.To)) ? "LEFT" : "gone")}"); + } + CarCloneResult? result = CarCloner.CloneExtracted(folders, Source, Name, traffic: true, Title, out string? refused); Check("clones shubert_38", result != null, refused ?? ""); if (result == null) return; diff --git a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs index 4f68e4c..a583c23 100644 --- a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs @@ -106,6 +106,23 @@ void Check(string name, bool ok, string detail = "") CarM2oExport.ExportFrom(sources with { Archive = misnamed, WinterArchive = null }, output, "car-test", out string? alien) == null && alien != null && !Directory.Exists(output), alien ?? ""); + // A folder whose manifests are there and do not read: left as it is, not replaced with this one car. + string broken = Path.Combine(scratch, "broken"); + Directory.CreateDirectory(broken); + const string BadList = "{ \"vehicles\": [ { \"model\": \"Earlier_Car\" }, ", BadPackage = "{ \"name\": \"mine\", \"custom\": tru"; + File.WriteAllText(Path.Combine(broken, CarM2oExport.VehiclesFile), BadList); + File.WriteAllText(Path.Combine(broken, CarM2oExport.PackageFile), "{ \"name\": \"mine\" }"); + Check("refuses a folder whose vehicles.json does not read, and leaves it and everything else as it was", + CarM2oExport.ExportFrom(sources, broken, "car-test", out string? unreadList) == null && unreadList != null + && File.ReadAllText(Path.Combine(broken, CarM2oExport.VehiclesFile)) == BadList + && Directory.GetFileSystemEntries(broken).Length == 2, unreadList ?? "exported"); + File.WriteAllText(Path.Combine(broken, CarM2oExport.VehiclesFile), "{ \"vehicles\": [] }"); + File.WriteAllText(Path.Combine(broken, CarM2oExport.PackageFile), BadPackage); + Check("refuses a folder whose package.json does not read, the same way", + CarM2oExport.ExportFrom(sources, broken, "car-test", out string? unreadPackage) == null && unreadPackage != null + && File.ReadAllText(Path.Combine(broken, CarM2oExport.PackageFile)) == BadPackage + && Directory.GetFileSystemEntries(broken).Length == 2, unreadPackage ?? "exported"); + CarM2oExportResult? result = CarM2oExport.ExportFrom(sources, output, CarM2oExport.DefaultResource(Name), out refused); Check("exports the clone", result != null, refused ?? ""); if (result == null) return; diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index e7f403c..92e1fba 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -966,7 +966,7 @@ where string.Equals(f.Name, field, StringComparison.OrdinalIgnoreCase) // The same bookkeeping the Tuning tab does: an undo entry, the archive on the build list, the panel re-read. D3DImageHost stage = window.TargetStage; void Reload() => window.Scene.Selection.ReloadTuning(); - stage.History.Push(new TuningValueEdit(change, Reload)); + stage.History.Push(new WorkingCopyEdit(new TuningValueEdit(change, Reload), () => stage.MarkArchiveModified(archive))); stage.MarkArchiveModified(archive); Reload(); stage.RaiseNotice($"{target.Name} set. Build to write it into the archive.", isError: false); diff --git a/src/Illusion/Viewport/WorkingCopyEdit.cs b/src/Illusion/Viewport/WorkingCopyEdit.cs new file mode 100644 index 0000000..68f158c --- /dev/null +++ b/src/Illusion/Viewport/WorkingCopyEdit.cs @@ -0,0 +1,27 @@ +using Illusion.Domain; + +namespace Illusion.Viewport; + +/// +/// An edit that is already in an archive's working copy when it is made — a tuning value, an effect — and +/// whose undo and redo write the working copy again. Each of those writes has to put the archive back on the +/// build list, exactly as the edit itself did: a Build takes the archive off the list, and an undo after it +/// used to change the working copy with nothing left to say the game's archive was now behind it — the next +/// Build packed nothing, and the game kept the value that had been undone. +/// +internal sealed class WorkingCopyEdit(IEditAction inner, Action enlist) : IEditAction +{ + public void Undo() + { + inner.Undo(); + enlist(); + } + + public void Redo() + { + inner.Redo(); + enlist(); + } + + public void Discard() => inner.Discard(); +} diff --git a/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs b/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs index 6e9f6b9..e4351ee 100644 --- a/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs +++ b/src/Illusion/Views/ScenePanel/ScenePanel.xaml.cs @@ -151,7 +151,7 @@ public void Attach(D3DImageHost viewport) // adds the archive to the build list and says so. _selection.TuningEdited += (archive, message, edit) => { - viewport.History.Push(edit); + viewport.History.Push(new WorkingCopyEdit(edit, () => viewport.MarkArchiveModified(archive))); viewport.MarkArchiveModified(archive); viewport.RaiseNotice(message + " Build to write it into the archive.", isError: false); }; @@ -159,7 +159,7 @@ public void Attach(D3DImageHost viewport) // And an effect edit — a birth rate, a colour key, a whole effect copied. _selection.EffectEdited += (archive, message, edit) => { - viewport.History.Push(edit); + viewport.History.Push(new WorkingCopyEdit(edit, () => viewport.MarkArchiveModified(archive))); viewport.MarkArchiveModified(archive); viewport.RaiseNotice(message + " Build to write it into the archive.", isError: false); }; From da89b7d0605846a0dc7c0534a320f4ecbb0c2e0e Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Mon, 5 Oct 2026 23:48:36 +0300 Subject: [PATCH 055/102] fix(import): review of #9 - the carry tries every copy the index knows The two findings of the review of this branch. The first - an archive extracted after the index was built is not in it - is answered by 91722f3 (the extractor announces the folder; the index checks the disk). What was left of the second: the lookup asked the index for ONE path and took a copy that could not lend - one in the destination itself, one its own archive does not list - as proof that no archive holds the texture. HolderOf now goes through every copy on disk (TextureSearchIndex.FindAll) and takes the first that is in another working copy and on that archive's list; a working copy whose manifest does not read lends nothing and is passed over. And the default lookup is covered by the probe, not only an injected path (asked for in the review): --probe-object-transplant 64/64 - a donor announced after the index was built is found by the carry's own lookup; a first copy that is not on its archive's list does not end the search; a donor that has gone and a copy that is the destination's own are passed over for the next donor. --- src/Illusion.Assets/Sds/ArchiveCarry.cs | 42 +++++++++++---- .../Textures/TextureSearchIndex.cs | 29 +++++++++++ .../Probes/ObjectTransplantProbes.cs | 52 +++++++++++++++++++ 3 files changed, 112 insertions(+), 11 deletions(-) diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index c36d303..a99235b 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -82,8 +82,12 @@ public static Report Carry(string fromDir, string toDir, IReadOnlyCollection(); var elsewhere = new List(); var borrowed = new List<(string Texture, string Archive)>(); - CarryTextures(from, to, fromDir, toDir, materialHashes, directTextures, textures, elsewhere, borrowed, - findElsewhere ?? TextureSearchIndex.FindPath); + // Every copy the index knows, not the first: a copy in the destination itself, or one its own archive + // does not list, is no donor — and it used to be taken as proof that there was none. + Func> candidates = findElsewhere == null + ? TextureSearchIndex.FindAll + : name => findElsewhere(name) is { } path ? [path] : []; + CarryTextures(from, to, fromDir, toDir, materialHashes, directTextures, textures, elsewhere, borrowed, candidates); var descriptions = new List(); var unresolved = new List(); @@ -95,7 +99,8 @@ public static Report Carry(string fromDir, string toDir, IReadOnlyCollection materialHashes, IReadOnlyCollection directTextures, List added, - List elsewhere, List<(string Texture, string Archive)> borrowed, Func findElsewhere) + List elsewhere, List<(string Texture, string Archive)> borrowed, + Func> findElsewhere) { MafiaMaterials.EnsureLoaded(); var wanted = new HashSet(directTextures.Where(t => !string.IsNullOrWhiteSpace(t)), @@ -149,15 +154,30 @@ private static void CarryTextures(SdsManifest from, SdsManifest to, string fromD } } - // The extracted archive, other than the destination, whose manifest lists the texture. - private static (SdsManifest Manifest, string Dir)? HolderOf(string texture, string toDir, Func find) + // The extracted archive, other than the destination, whose manifest lists the texture: the first of the + // copies on offer that is one. A copy that is not — gone from disk, in the destination, in a folder that + // is no working copy, or not on its archive's list — is passed over for the next. + private static (SdsManifest Manifest, string Dir)? HolderOf(string texture, string toDir, + Func> find) { - string? path = find(texture); - string? dir = path == null ? null : Path.GetDirectoryName(path); - if (dir == null || !File.Exists(path) || !File.Exists(Path.Combine(dir, "SDSContent.xml"))) return null; - if (string.Equals(Path.GetFullPath(dir), Path.GetFullPath(toDir), StringComparison.OrdinalIgnoreCase)) return null; - SdsManifest manifest = SdsManifest.Load(dir); - return manifest.HasFile(texture) ? (manifest, dir) : null; + string destination = Path.GetFullPath(toDir); + foreach (string path in find(texture)) + { + string? dir = Path.GetDirectoryName(path); + if (dir == null || !File.Exists(path) || !File.Exists(Path.Combine(dir, "SDSContent.xml"))) continue; + if (string.Equals(Path.GetFullPath(dir), destination, StringComparison.OrdinalIgnoreCase)) continue; + SdsManifest manifest; + try + { + manifest = SdsManifest.Load(dir); + } + catch (Exception ex) when (ex is IOException or Formats.SdsFormatException or System.Xml.XmlException) + { + continue; // a working copy whose manifest does not read lends nothing + } + if (manifest.HasFile(texture)) return (manifest, dir); + } + return null; } /// What a working copy's lists held before a carry, so that a carry whose import then fails can diff --git a/src/Illusion.Assets/Textures/TextureSearchIndex.cs b/src/Illusion.Assets/Textures/TextureSearchIndex.cs index da3610d..7571f61 100644 --- a/src/Illusion.Assets/Textures/TextureSearchIndex.cs +++ b/src/Illusion.Assets/Textures/TextureSearchIndex.cs @@ -105,6 +105,35 @@ public static void RegisterFolder(string folder) } } + /// Every copy of a texture name in the mirror that is on disk now, the first found first. For a + /// caller that cannot use just any copy — one that needs it in an archive that lists it, and not in the + /// folder it is carrying into: the first copy failing that test is not proof that no copy passes it. + public static IReadOnlyList FindAll(string? name) + { + if (string.IsNullOrEmpty(name)) return []; + EnsureBuilt(); + lock (Sync) + { + if (_byName == null || !_byName.TryGetValue(name, out List? holders)) return []; + var paths = new List(holders.Count); + for (int i = 0; i < holders.Count;) + { + string path = Path.Combine(Folders[holders[i]], name); + if (File.Exists(path)) + { + paths.Add(path); + i++; + } + else + { + holders.RemoveAt(i); + } + } + if (holders.Count == 0) _byName.Remove(name); + return paths; + } + } + /// Full path of a texture name anywhere in the mirror, or null — a file that is there now: a /// copy that has been removed since it was indexed is forgotten and the next one answers. Blocks on the /// first call if the background build has not finished yet (WarmUp makes that rare). diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index 9787028..dfc3853 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -443,6 +443,58 @@ void PutBack() $"first {firstWins}, second {fallsThrough}"); } + // ── The carry through the index itself: donors that appear late, and a first copy that is no donor ── + if (Illusion.Assets.Textures.TextureSearchIndex.IsBuilt) + { + const string Lent = "illusion_probe_lent.dds"; + string Working(string folder) + { + string made = Path.Combine(scratch, folder); + Directory.CreateDirectory(made); + File.WriteAllText(Path.Combine(made, "SDSContent.xml"), "\n"); + return made; + } + string Donor(string folder, byte mark, bool listed = true) + { + string made = Working(folder); + File.WriteAllBytes(Path.Combine(made, Lent), [mark, 2, 3]); + if (listed) SdsManifest.Load(made).AddEntry("Texture", Lent, 2, [("HasMIP", "0")]); + return made; + } + string nothing = Working("lend_from"), into = Working("lend_into"); + ArchiveCarry.Report Bring() => ArchiveCarry.Carry(nothing, into, [], [], null, directTextures: [Lent]); + + ArchiveCarry.Report beforeDonors = Bring(); + string unlisted = Donor("lend_unlisted", 7, listed: false), good = Donor("lend_good", 9); + // As the extractor announces a folder it has just made — in this order, so the copy its own + // archive does not list is the one the index offers first. + Illusion.Assets.Textures.TextureSearchIndex.RegisterFolder(unlisted); + Illusion.Assets.Textures.TextureSearchIndex.RegisterFolder(good); + ArchiveCarry.Report afterDonors = Bring(); + Check("a texture in an archive extracted after the index was built is found by the carry's own lookup", + beforeDonors.Elsewhere.Contains(Lent) && afterDonors.Textures.Contains(Lent) + && afterDonors.Borrowed.Any(b => b.Texture == Lent && b.Archive == "lend_good"), + $"before: {(beforeDonors.Elsewhere.Contains(Lent) ? "held nowhere" : "found")}; after: from " + + string.Join(", ", afterDonors.Borrowed.Select(b => b.Archive))); + Check("the first copy the index offers not being on its archive's list does not end the search", + File.Exists(Path.Combine(into, Lent)) && File.ReadAllBytes(Path.Combine(into, Lent))[0] == 9); + + // Now a donor has gone, and the destination's own copy stands before the one that is left. + if (SdsManifest.Load(into).RemoveEntry(Lent)) File.Delete(Path.Combine(into, Lent)); + File.Delete(Path.Combine(unlisted, Lent)); + File.Delete(Path.Combine(good, Lent)); + string own = Path.Combine(into, Lent); + File.WriteAllBytes(own, [5]); // on disk in the destination, not on its list + Illusion.Assets.Textures.TextureSearchIndex.Register(own); + string third = Donor("lend_third", 11); + Illusion.Assets.Textures.TextureSearchIndex.RegisterFolder(third); + ArchiveCarry.Report onceMore = Bring(); + Check("a donor that has gone, and a copy that is the destination's own, are passed over for the next donor", + onceMore.Textures.Contains(Lent) && onceMore.Borrowed.Any(b => b.Archive == "lend_third") + && File.ReadAllBytes(own)[0] == 11, + onceMore.Elsewhere.Contains(Lent) ? "reported as held nowhere" : "from " + string.Join(", ", onceMore.Borrowed.Select(b => b.Archive))); + } + // ── A destination that has neither a prefab nor an item description of its own ── string bare = Path.Combine(scratch, "bare"); Directory.CreateDirectory(bare); From d91030ddb2dcab34ca1d1253c49ce5b102874be0 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:06:44 +0300 Subject: [PATCH 056/102] fix(props): the picture cache of an earlier build is left alone 45236da gave a prop's kept picture a name that carries the state of its archive, and on first use deleted every picture under the old name as something nothing reads. A build from before that change, in use beside this one on the same folder, reads exactly those: its Props tab lost all 2,928 pictures the first time a probe of this branch ran. Only a prop's own pictures from earlier states of its archive are dropped now. --probe-prop-catalog 12/12; 2,928 old-format pictures still there after it. --- src/Illusion/Viewport/PropThumbnailRenderer.cs | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/src/Illusion/Viewport/PropThumbnailRenderer.cs b/src/Illusion/Viewport/PropThumbnailRenderer.cs index ebe9d1d..ddb7a9a 100644 --- a/src/Illusion/Viewport/PropThumbnailRenderer.cs +++ b/src/Illusion/Viewport/PropThumbnailRenderer.cs @@ -224,8 +224,9 @@ private static string StemOf(PropEntry entry) return Path.Combine(CacheDir, $"{Fnv64.Hash(entry.Key):x16}_{Fnv64.Hash($"{copy}|{Look}"):x16}"); } - private static bool _legacyDropped; - + // Only this prop's own pictures from earlier states of its archive. Pictures named the way the first + // version of this named them (the prop alone, no state) are left where they are: a build of that version + // may be in use beside this one, on the same folder — and deleting "what nothing reads" emptied its tiles. private static void DropOlder(string stem) { string whose = Path.GetFileName(stem)[..17]; // sixteen digits and the underscore @@ -236,13 +237,6 @@ private static void DropOlder(string stem) File.Delete(other); } } - if (_legacyDropped) return; - _legacyDropped = true; - // Pictures under the name the first version of this gave them (no underscore): nothing reads them. - foreach (string old in Directory.GetFiles(CacheDir, "*.png")) - { - if (!Path.GetFileName(old).Contains('_')) File.Delete(old); - } } private static ImageSource? Load(string path) From 4034b0f4c0b0c0fdb38e6ea26e6e9748446048d7 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:30:44 +0300 Subject: [PATCH 057/102] fix(bridge): what a second look at 838c69a found 838c69a made the texture files of a push part of its undo and put a refused material's files back. Read again before it went up: Undo removed a texture the push had introduced. The material that names it is created by an edit of its own and outlives the push's entry, and Blender - told the push arrived - sends pixels again only when the image changes: after one Ctrl+Z the texture was in no archive, and the next push put the material on the object with nothing to draw it from. The undo entry takes back pictures that were REPLACED; an introduced one stays with its material. Undo and redo did not put the archives back on the build list. Push, Build, Ctrl+Z: the folder had the old picture, the game's archive the new one, and Build answered that there was nothing to build. A push that only repainted a texture was dropped from the history by the first unload of any district: its entry names no objects, and 'all of its objects have left the scene' is true of none. A write that failed was remembered as done. The name was noted before the write; the next object of the push sharing the image was handed it as success and its material named a file that was never made. Noted after. A texture half written - the entry replaced and its top level, or the manifest, not - was caught with nothing on record to put it back from. It is put back before the failure is passed on, and neither that nor AtomicFile leaves its .tmp beside the target any more. An object is refused before any of its materials is touched: every slot is checked first for what can be refused without writing (normal map space, images that did not arrive whole). A bad second slot used to be found after the first slot's pictures had been replaced. A texture that cannot be copied into the object's archive refuses the object as well. --probe-bridge-material 61/61 (3 new: the two-slot object; a texture held open refuses both objects sharing the image and leaves the picture, the manifest and the folder as they were). --probe-material-editor 65/65. The undo entry itself (introduced texture kept, archives re-enlisted, the entry surviving an unload) is not covered by a probe - it lives behind the editor's history. --- src/Illusion.Assets/AtomicFile.cs | 15 ++- .../Bridge/AuthoredMaterialResolver.cs | 96 ++++++++++++++----- .../Textures/ArchiveTextureWriter.cs | 7 +- .../Bridge/BridgeSessionController.cs | 2 +- .../Probes/BridgeMaterialProbes.cs | 50 +++++++++- src/Illusion/Viewport/DistrictStreamer.cs | 5 +- src/Illusion/Viewport/TextureFilesEdit.cs | 28 +++++- 7 files changed, 163 insertions(+), 40 deletions(-) diff --git a/src/Illusion.Assets/AtomicFile.cs b/src/Illusion.Assets/AtomicFile.cs index 0961acc..735061b 100644 --- a/src/Illusion.Assets/AtomicFile.cs +++ b/src/Illusion.Assets/AtomicFile.cs @@ -10,7 +10,18 @@ internal static class AtomicFile public static void WriteAllBytes(string path, byte[] bytes) { string tmp = path + ".tmp"; - File.WriteAllBytes(tmp, bytes); - File.Move(tmp, path, overwrite: true); + try + { + File.WriteAllBytes(tmp, bytes); + File.Move(tmp, path, overwrite: true); + } + catch + { + // The target is untouched, which is the point — but the temp file is ours, and a write that + // failed (the target held open by something) used to leave it lying beside it. + try { File.Delete(tmp); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { /* then it stays */ } + throw; + } } } diff --git a/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs b/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs index 2aba6b4..68e9c89 100644 --- a/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs +++ b/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs @@ -75,20 +75,33 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s { skipReason = null; MafiaMaterials.EnsureLoaded(); + + // First, every slot of the object for what can be refused without writing anything: an object is + // applied whole or skipped whole, and "skipped" has to mean that none of its materials was touched. + // Looked at slot by slot as they were resolved, a bad second slot was found after the first slot's + // pictures had already been replaced. foreach (MeshMaterialInfo info in payload.Materials) { - bool hasHash = BridgeMeshApplier.TryParseMaterialHash(info.Hash, out ulong hash) && hash != 0; string name = info.Name?.Trim() ?? ""; - // A normal map in object or world space cannot become the game's: its channels are directions // in another frame, and packed as tangent-space X and Y they light the surface wrongly with - // nothing to say why. Refused by name, before anything of the material is written. + // nothing to say why. Refused by name. if (info.NormalSpace is { Length: > 0 } space && !space.Equals("TANGENT", StringComparison.OrdinalIgnoreCase)) { skipReason = $"material '{name}': its Normal Map node is in {Friendly(space)} space, and the game reads " + "tangent-space normal maps only — set the node's Space to Tangent (or bake the map to tangent space)"; return false; } + foreach (MaterialImageRef? image in new[] { info.DiffuseImage, info.NormalImage, info.SpecularImage }) + { + if (image != null && Pixels(image, out skipReason) == null) return false; + } + } + + foreach (MeshMaterialInfo info in payload.Materials) + { + bool hasHash = BridgeMeshApplier.TryParseMaterialHash(info.Hash, out ulong hash) && hash != 0; + string name = info.Name?.Trim() ?? ""; // Blender remembers a material this bridge created, but the library may not: the creation was // undone, or the toolkit closed without a Save. With the pixels at hand it is simply made again; @@ -113,7 +126,7 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s if (!info.Authored) continue; if (info.DiffuseImage == null) { - Carry(document, hash); + if ((skipReason = Carry(document, hash)) != null) return false; continue; } lock (CreatedHere) CreatedHere.Add(hash); // stamped by an earlier session's ack @@ -153,7 +166,7 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s if (updated.NormalSpecular != null && string.Equals(updated.NormalSpecular, current.Normal, StringComparison.OrdinalIgnoreCase)) NoteRewritten(hash, updated.NormalSpecular); - Carry(document, hash); + if ((skipReason = Carry(document, hash)) != null) return false; Acknowledge(name, hash); continue; } @@ -170,7 +183,7 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s // of it is copied anywhere: its textures are wherever the game keeps them. bool ours; lock (CreatedHere) ours = CreatedHere.Contains(known); - if (ours) Carry(document, known); + if (ours && (skipReason = Carry(document, known)) != null) return false; Acknowledge(name, known); continue; } @@ -210,7 +223,9 @@ private void NoteRewritten(ulong hash, string texture) /// objects of two archives therefore has to be in both, and the one that did not create it gets a copy /// of the files the other holds — pixels or no pixels in this push. /// - private void Carry(ISceneDocument document, ulong hash) + /// Null, or why a texture could not be written into that archive — the object is then not + /// applied: it would wear a material its archive cannot show. + private string? Carry(ISceneDocument document, ulong hash) { string dir = SdsMeshLoader.EnsureExtracted(document.SourceArchive); MafiaMaterials.MaterialTextures textures = MafiaMaterials.GetMaterialTextures(hash); @@ -219,14 +234,47 @@ private void Carry(ISceneDocument document, ulong hash) { if (string.IsNullOrEmpty(texture) || File.Exists(Path.Combine(dir, texture))) continue; if (TextureSearchIndex.FindPath(texture) is not { } source || !File.Exists(source)) continue; - ArchiveTextureWriter.TextureState held = - ArchiveTextureWriter.Read(Path.GetDirectoryName(source)!, Path.GetFileName(source)); - if (held.Texture == null) continue; - - ArchiveTextureWriter.TextureState before = ArchiveTextureWriter.Read(dir, texture); - ArchiveTextureWriter.Write(dir, texture, held.Texture, held.TopLevel); - TextureChanges.Add(new TextureChange(dir, texture, before, held)); - TouchedArchives[document.SourceArchive.FullName] = document.SourceArchive; + try + { + ArchiveTextureWriter.TextureState held = + ArchiveTextureWriter.Read(Path.GetDirectoryName(source)!, Path.GetFileName(source)); + if (held.Texture == null) continue; + + ArchiveTextureWriter.TextureState before = ArchiveTextureWriter.Read(dir, texture); + WriteOrPutBack(dir, texture, before, held.Texture, held.TopLevel); + TextureChanges.Add(new TextureChange(dir, texture, before, held)); + TouchedArchives[document.SourceArchive.FullName] = document.SourceArchive; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return $"could not copy the texture '{texture}' into {document.SourceArchive.Name} — {ex.Message}"; + } + } + return null; + } + + // A texture is two files and two manifest entries, written one after another. When one of those steps + // fails, the ones before it have already happened — a new picture under an old top level, or files the + // manifest does not list — and nothing was on record to put them back from. Put back here, as far as + // the folder allows, before the failure is passed on. + private static void WriteOrPutBack(string dir, string file, ArchiveTextureWriter.TextureState before, + byte[] texture, byte[]? topLevel) + { + try + { + ArchiveTextureWriter.Write(dir, file, texture, topLevel); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + try + { + ArchiveTextureWriter.Restore(dir, file, before); + } + catch (Exception again) when (again is IOException or UnauthorizedAccessException) + { + // whatever holds the folder holds it still; the first failure is the one to report + } + throw; } } @@ -348,14 +396,18 @@ private static string Format(ulong hash) => (byte[] texture, byte[]? topLevel) = DdsEncoder.Encode(rgba, width, height); string file = ArchiveTextureWriter.PickName(dir, imageName, reuse, texture, topLevel); ArchiveTextureWriter.TextureState before = ArchiveTextureWriter.Read(dir, file); - _written[(dir, key)] = file; // The same picture under the same name is nothing to write, undo or reload. - if (before.Is(texture, topLevel)) return file; - - ArchiveTextureWriter.Write(dir, file, texture, topLevel); - TextureChanges.Add(new TextureChange( - dir, file, before, new ArchiveTextureWriter.TextureState(texture, topLevel))); - TouchedArchives[document.SourceArchive.FullName] = document.SourceArchive; + if (!before.Is(texture, topLevel)) + { + WriteOrPutBack(dir, file, before, texture, topLevel); + TextureChanges.Add(new TextureChange( + dir, file, before, new ArchiveTextureWriter.TextureState(texture, topLevel))); + TouchedArchives[document.SourceArchive.FullName] = document.SourceArchive; + } + // Remembered only once it IS written: noted before the write, a write that failed was handed + // to the next object sharing the image as done, and that object's material named a file that + // was never made. + _written[(dir, key)] = file; return file; } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) diff --git a/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs b/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs index a51b9f6..869ceca 100644 --- a/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs +++ b/src/Illusion.Assets/Textures/ArchiveTextureWriter.cs @@ -120,12 +120,7 @@ public static void Remove(string extractedDir, string fileName) File.Delete(Path.Combine(extractedDir, mipName)); } - private static void Replace(string path, byte[] bytes) - { - string temp = path + ".tmp"; - File.WriteAllBytes(temp, bytes); - File.Move(temp, path, overwrite: true); - } + private static void Replace(string path, byte[] bytes) => AtomicFile.WriteAllBytes(path, bytes); // Texture names are hashed as written and travel through XML and file systems: keep them to the // plain characters stock names use. diff --git a/src/Illusion/Bridge/BridgeSessionController.cs b/src/Illusion/Bridge/BridgeSessionController.cs index 336e8a3..3c948a2 100644 --- a/src/Illusion/Bridge/BridgeSessionController.cs +++ b/src/Illusion/Bridge/BridgeSessionController.cs @@ -945,7 +945,7 @@ private void ApplyPush(PushMessage push) // The catalog's own history cannot do it — a repaint under the same name changes no binding // and so leaves no entry there at all. if (authored.TextureChanges.Count > 0) - collisionEdits.Add(new TextureFilesEdit(_host, authored.TextureChanges)); + collisionEdits.Add(new TextureFilesEdit(_host, authored.TextureChanges, authored.TouchedArchives.Values)); foreach (ReshapedHull hull in reshapes) { if (!_host.Tree.IsInScene(hull.Node) || hull.Node.Parent is not { } layer diff --git a/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs b/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs index 6da15bc..a20f579 100644 --- a/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs +++ b/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs @@ -363,6 +363,48 @@ static string Shape(Formats.Materials.Versions.Material_v57 m) => Check("…and the files written for it are put back", File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) && unlucky.TextureChanges.Count == 0); + + // An object with two slots: a valid repaint first, a material that cannot be made second. + // The object is skipped — and skipped has to mean the first slot's picture was not replaced. + var mixed = new ExchangeContainer(); + MeshMaterialInfo good = NewSlot(mixed, Gradient(w, h, 150), w, h); + good.Hash = slot.Hash; + MeshMaterialInfo bad = NewSlot(mixed, Gradient(w, h, 151), w, h); + bad.Name = ProbeMaterial + "_bad"; + bad.NormalImage = new MaterialImageRef { Name = "illusion_probe_missing.png", Width = 64, Height = 64, Block = 9999 }; + var twoSlots = new AuthoredMaterialResolver(mixed, catalogHost); + bool both = twoSlots.TryResolve( + new MeshObjectPayload { Id = "new:probe", Name = "probe", Materials = { good, bad } }, document, out reason); + Check("an object whose second material cannot be made is refused before its first material is touched", + !both && File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) + && twoSlots.TextureChanges.Count == 0 && MafiaMaterials.FindHashByName(bad.Name) == null, reason ?? "resolved"); + + // A write that fails — the texture held open by something — is not remembered as done: the + // next object of the same push sharing the image is refused too, not handed a file that was + // never written. + var shared = new ExchangeContainer(); + MeshMaterialInfo one = NewSlot(shared, Gradient(w, h, 170), w, h); + one.Hash = slot.Hash; + var sameImage = new MeshMaterialInfo + { + Hash = slot.Hash, Name = ProbeMaterial, Authored = true, DiffuseImage = one.DiffuseImage, + }; + var blocked = new AuthoredMaterialResolver(shared, catalogHost); + bool firstTaken, secondTaken; + string? firstWhy, secondWhy; + using (new FileStream(Path.Combine(extracted, held), FileMode.Open, FileAccess.Read, FileShare.Read)) + { + firstTaken = blocked.TryResolve( + new MeshObjectPayload { Id = "new:probe1", Name = "probe", Materials = { one } }, document, out firstWhy); + secondTaken = blocked.TryResolve( + new MeshObjectPayload { Id = "new:probe2", Name = "probe", Materials = { sameImage } }, document, out secondWhy); + } + Check("a texture that cannot be written refuses the object, and the next object sharing the image as well", + !firstTaken && !secondTaken && firstWhy != null && secondWhy != null, $"{firstWhy} / {secondWhy ?? "resolved"}"); + Check("…leaving the picture as it was, no temp file beside it, and nothing on record as written", + File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) + && !File.Exists(Path.Combine(extracted, held + ".tmp")) && blocked.TextureChanges.Count == 0 + && SdsManifest.Load(extracted).HasFile(held)); } // ── What a push wrote can be taken back, and put back ── @@ -405,7 +447,9 @@ static string Shape(Formats.Materials.Versions.Material_v57 m) => File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(repainted)); } - // A texture the push INTRODUCED goes away with it — file and manifest entry both. + // A texture a push INTRODUCED can be taken out again — file and manifest entry both. (That is + // what puts a refused material's files back; the push's undo entry leaves an introduced + // texture where it is, since the material that names it stays.) var novel = new ExchangeContainer(); MeshMaterialInfo born = NewSlot(novel, Gradient(256, 256, 5), 256, 256); born.Name = ProbeMaterial + "_born"; @@ -421,11 +465,11 @@ static string Shape(Formats.Materials.Versions.Material_v57 m) => { ArchiveTextureWriter.Restore(introduced.Dir, introduced.File, introduced.Before); SdsManifest gone = SdsManifest.Load(extracted); - Check("undo removes it: neither file nor manifest entry is left, and the folder still packs", + Check("taken back, neither file nor manifest entry is left, and the folder still packs", !File.Exists(Path.Combine(extracted, introduced.File)) && !File.Exists(Path.Combine(extracted, "MIP_" + introduced.File)) && !gone.HasFile(introduced.File) && !gone.HasFile("MIP_" + introduced.File) && Packs(extracted, out _)); ArchiveTextureWriter.Restore(introduced.Dir, introduced.File, introduced.After); - Check("redo brings it back with its top level", + Check("put back, it is there with its top level", ArchiveTextureWriter.Read(extracted, introduced.File).Is(introduced.After.Texture!, introduced.After.TopLevel)); } } diff --git a/src/Illusion/Viewport/DistrictStreamer.cs b/src/Illusion/Viewport/DistrictStreamer.cs index e1d53d9..596db08 100644 --- a/src/Illusion/Viewport/DistrictStreamer.cs +++ b/src/Illusion/Viewport/DistrictStreamer.cs @@ -1475,7 +1475,10 @@ private void UnloadDistrict(string name) // Drop undo/redo entries whose objects have ALL left the scene: those in THIS district (about to // detach) plus any already detached by an earlier unload — covers cross-district group edits too. // (Discard on the dropped edits releases any detached-delete meshes they were holding.) - _host.Editing.History.RemoveWhere(a => a is INodeEdit ne && + // An entry that names no objects at all is not one of those — "all of none" is true of every + // district: a push that only repainted a texture was dropped from the history by the first + // unload of anything, and Ctrl+Z then undid whatever came before it. + _host.Editing.History.RemoveWhere(a => a is INodeEdit ne && ne.Nodes.Any() && ne.Nodes.All(n => SceneTree.IsSelfOrDescendantOf(n, sds) || !_host.Tree.IsInScene(n))); // The unloaded frame resource can no longer be saved from memory — drop its persistence flags. if (_host.Persistence.PruneEditedFrames(n => SceneTree.IsSelfOrDescendantOf(n, sds))) diff --git a/src/Illusion/Viewport/TextureFilesEdit.cs b/src/Illusion/Viewport/TextureFilesEdit.cs index b23b6ab..1effb7a 100644 --- a/src/Illusion/Viewport/TextureFilesEdit.cs +++ b/src/Illusion/Viewport/TextureFilesEdit.cs @@ -1,3 +1,4 @@ +using System.IO; using Illusion.Assets.Bridge; using Illusion.Assets.Textures; using Illusion.Domain; @@ -11,19 +12,31 @@ namespace Illusion.Viewport; /// after a repaint the catalog has nothing to undo, and Ctrl+Z took back the mesh while the old picture was /// gone for good (or took back some earlier, unrelated edit when the push had changed nothing else). The /// bytes each name held before and after are kept here instead, with the manifest entries that go with -/// them: undo puts the old files back — or removes a texture the push introduced — and redo the new ones. +/// them: undo puts the old files back and redo the new ones. +/// +/// +/// Only a picture that was REPLACED is taken back. One the push introduced stays: the material that names it +/// was created by an edit of its own and outlives this one, and Blender — which has been told the push +/// arrived — sends the pixels again only when the image changes. Removed here, the texture was gone from +/// every archive while its material went on naming it, and the next push put that material on the object +/// with nothing to draw it from. /// /// internal sealed class TextureFilesEdit : IEditAction { private readonly D3DImageHost _host; private readonly IReadOnlyList _changes; + private readonly IReadOnlyList _archives; private bool _onDisk = true; // the resolver has already written them when the push is applied - public TextureFilesEdit(D3DImageHost host, IReadOnlyList changes) + /// The archives whose folders the changes are in — each has to be built again + /// after an undo or a redo, exactly as after the push itself. + public TextureFilesEdit(D3DImageHost host, IReadOnlyList changes, + IEnumerable archives) { _host = host; - _changes = [.. changes]; + _changes = [.. changes.Where(c => c.Before.Exists)]; + _archives = [.. archives]; } public void Undo() @@ -49,7 +62,12 @@ public void Redo() } // The folders changed under a renderer that holds the old pictures, and under archives whose .sds is - // now behind (or, after an undo, no longer ahead in that respect — but still not what was last packed). - private void Reload() => + // now behind. A Build since the push took those archives off the build list; without putting them back + // an undo left the folder with the old picture, the game's archive with the new one and nothing to say so. + private void Reload() + { + if (_changes.Count == 0) return; _host.MaterialEditing.ReloadTextureFiles(_changes.Select(c => c.File)); + foreach (FileInfo archive in _archives) _host.MarkArchiveModified(archive); + } } From 01973babe504e84ebeee15d86e22517f7e11ace4 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:33:36 +0300 Subject: [PATCH 058/102] fix(mcp): a layer is not switched off over unsaved edits; moves and imports take finite numbers A second look at f12155b. editor_open_area was given a guard against dropping unsaved edits, and view_set was left without one: switching the crash (or collision) layer off unloads it, and an unsaved move of a crash copy went with it, undo entry included - the same loss by a shorter road, and an easier one to take now that scene_find hands out crash copies. Refused while the scene has unsaved edits, unless discardUnsavedEdits is passed; switching a layer on, or off with nothing unsaved, is as before. object_move and actor_import took a position or an offset as it came: a number too large for a float arrives as Infinity and was written into the transform. They refuse what is not finite, as property values already do. --probe-editor-tools-live (new; a real window with a district loaded, nothing saved) 12/12: both refusals of non-finite numbers; the layer off and on with nothing edited; a crash copy moved, the layer refused, zones still switch on, the layer off once the edits are given up. --probe-editor-tools 23/23, --probe-mcp 66/66. --- src/Illusion.Mcp/IEditorSession.cs | 3 +- src/Illusion.Mcp/Tools/EditorTools.cs | 5 +- src/Illusion/Diagnostics/ProbeRunner.cs | 5 + .../Probes/EditorToolLiveProbes.cs | 107 ++++++++++++++++++ src/Illusion/Mcp/AppEditorSession.cs | 16 ++- 5 files changed, 132 insertions(+), 4 deletions(-) create mode 100644 src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs diff --git a/src/Illusion.Mcp/IEditorSession.cs b/src/Illusion.Mcp/IEditorSession.cs index fc5c8e5..50218c5 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -74,7 +74,8 @@ public interface IEditorSession bool FrameSelection(); /// Switches the shading mode and the overlay layers; a null argument leaves that one alone. - string? SetView(string? renderMode, bool? collision, bool? crash, bool? zones, bool? navigation); + string? SetView(string? renderMode, bool? collision, bool? crash, bool? zones, bool? navigation, + bool discardUnsavedEdits = false); /// Renders the viewport's current view into a PNG. Null on success. string? Screenshot(string path, int width, int height); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 6f8c905..a5962b6 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -380,11 +380,12 @@ public static async Task SetView( [Description("Show collision hulls.")] bool? collision = null, [Description("Show the city_crash prop layer.")] bool? crash = null, [Description("Show district load zones.")] bool? zones = null, - [Description("Show AI navigation overlays.")] bool? navigation = null) + [Description("Show AI navigation overlays.")] bool? navigation = null, + [Description("Switch a layer off even though the scene has unsaved edits — those made in that layer are lost with their undo entries. Default false.")] bool discardUnsavedEdits = false) { try { - if (await ui.RunAsync(() => editor.SetView(renderMode, collision, crash, zones, navigation)) is { } refused) + if (await ui.RunAsync(() => editor.SetView(renderMode, collision, crash, zones, navigation, discardUnsavedEdits)) is { } refused) return ToolResult.Invalid(refused); return ToolResult.Json(new { success = true, status = await ui.RunAsync(editor.Status) }); } diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 0b8e349..8a65e8d 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -630,6 +630,11 @@ public static bool TryRun(string[] args) case "--probe-mcp": McpProbes.RunMcpProbe(); return true; + // The same tools against the editor itself: a real window with a district loaded. Nothing is saved. + // Output: %TEMP%\illusion_editor_tools_live.txt + case "--probe-editor-tools-live": + EditorToolLiveProbes.Run(args.Length >= 2 ? args[1] : "hill"); + return true; // The MCP editor tools against a scripted editor: what a save, a build and an area load tell // the caller when the editor could not do it, and which property values are refused. No game // install needed. Output: %TEMP%\illusion_editor_tools.txt diff --git a/src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs b/src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs new file mode 100644 index 0000000..1d6df63 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs @@ -0,0 +1,107 @@ +using System.IO; +using System.Text; +using Illusion.Mcp; +using Illusion.Views; + +namespace Illusion.Diagnostics.Probes; + +/// +/// The editor tools against the editor itself: a real window with a district loaded, driven through the same +/// session object the MCP tools call. What the scripted editor of cannot +/// answer — whether a guard really sees the edits it is there to protect. Nothing is saved. +/// +internal static class EditorToolLiveProbes +{ + // Output: %TEMP%\illusion_editor_tools_live.txt + internal static void Run(string area) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_editor_tools_live.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)}"); + } + + MainWindow? window = null; + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + window = new MainWindow + { + WindowState = System.Windows.WindowState.Normal, WindowStartupLocation = System.Windows.WindowStartupLocation.Manual, + Left = -4000, Top = 0, Width = 1280, Height = 800, ShowInTaskbar = false, ShowActivated = false, + }; + window.Show(); + var session = new AppEditorSession(); + Pump(() => session.Areas().Count > 0, 60); + string? refused = session.LoadArea(area, winter: false, discardUnsavedEdits: true); + Pump(() => session.Status() is { Loading: false, Meshes: > 0 }, 240); + Check("the district loads in a window of this process", refused == null && session.Status() is { Loading: false, Meshes: > 0 }, + refused ?? $"{session.Status().Meshes} meshes"); + if (session.Status() is not { Loading: false, Meshes: > 0 }) return; + + // ── numbers that are not numbers ── + SceneObjectInfo? mesh = session.Find(null, "Mesh", null, null, 1).FirstOrDefault(); + Check("the scene has a mesh to name", mesh != null); + if (mesh != null) + { + Check("object_move refuses a position that is not finite", + session.Move(mesh.Name, [float.PositiveInfinity, 0f, 0f], null) != null && !session.Status().UnsavedEdits); + Check("and an offset that is not finite", + session.Move(mesh.Name, null, [0f, float.NaN, 0f]) != null && !session.Status().UnsavedEdits); + } + Check("actor_import refuses a position that is not finite", + session.ImportActor(Path.Combine(Path.GetTempPath(), "illusion_no_such.act"), "x", "y", [0f, float.NegativeInfinity, 0f]) + is { } why && why.Contains("finite")); + + // ── a layer switched off under unsaved edits ── + Check("the crash layer switches on", session.SetView(null, null, crash: true, null, null) == null); + Pump(() => session.Find(null, "CrashInstance", null, null, 1).Count > 0, 90); + SceneObjectInfo? copy = session.Find(null, "CrashInstance", null, null, 1).FirstOrDefault(); + Check("a crash copy is found", copy != null, copy?.Path ?? ""); + if (copy != null) + { + Check("with nothing edited, switching the layer off and on is allowed", + session.SetView(null, null, crash: false, null, null) == null + && session.SetView(null, null, crash: true, null, null) == null); + Pump(() => session.Find(null, "CrashInstance", null, null, 1).Count > 0, 90); + copy = session.Find(null, "CrashInstance", null, null, 1).FirstOrDefault(); + string? moved = copy == null ? "no copy after the layer came back" : session.Move(copy.Path, null, [0f, 0f, 0.25f]); + Check("the copy is moved and the scene has unsaved edits", moved == null && session.Status().UnsavedEdits, moved ?? ""); + string? guarded = session.SetView(null, null, crash: false, null, null); + Check("switching the crash layer off is refused while that move is unsaved", + guarded != null && session.Status().UnsavedEdits && window.CrashToggle.IsChecked == true, guarded ?? "switched off"); + Check("switching another layer ON is not what the guard is about", + session.SetView(null, null, null, zones: true, null) == null); + Check("and with the edits given up it is switched off", + session.SetView(null, null, crash: false, null, null, discardUnsavedEdits: true) == null + && window.CrashToggle.IsChecked != true); + } + } + 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, $"EDITOR TOOLS LIVE PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static void Pump(Func until, int seconds) + { + DateTime end = DateTime.UtcNow.AddSeconds(seconds); + while (!until() && DateTime.UtcNow < end) + { + System.Windows.Threading.Dispatcher.CurrentDispatcher.Invoke( + () => { }, System.Windows.Threading.DispatcherPriority.Background); + Thread.Sleep(15); + } + } +} diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 1eaed53..00e8062 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -386,7 +386,8 @@ public CameraInfo Camera() public bool FrameSelection() => Host.FrameSelection(); - public string? SetView(string? renderMode, bool? collision, bool? crash, bool? zones, bool? navigation) + public string? SetView(string? renderMode, bool? collision, bool? crash, bool? zones, bool? navigation, + bool discardUnsavedEdits = false) { if (Window is not { } window) return NotOpen; D3DImageHost host = window.Viewport; @@ -396,6 +397,16 @@ public CameraInfo Camera() return $"unknown shading mode '{renderMode}' — one of {string.Join(", ", Enum.GetNames())}"; if ((collision != null || crash != null) && host.BridgeEditedCount > 0) return "the collision and crash layers reload part of the scene — blender_end first"; + // Switching a layer OFF unloads what it shows, and with it the unsaved edits made there and their + // undo entries — a crash copy moved and never saved is simply gone. The same loss editor_open_area + // refuses, by the same rule: not while edits are unsaved, unless the caller says they may go. + bool unloads = (crash == false && window.CrashToggle.IsChecked == true) + || (collision == false && window.CollisionToggle.IsChecked == true); + if (unloads && host.HasUnsavedEdits && !discardUnsavedEdits) + { + return "switching that layer off unloads it, and unsaved edits made in it are dropped together with " + + "their undo entries — editor_save first, or pass discardUnsavedEdits=true to give them up"; + } if (renderMode != null) { @@ -444,6 +455,8 @@ public CameraInfo Camera() if (host.BridgeEditedCount > 0 && !host.BridgeSession.IsEditedNode(node)) return $"'{name}' is not part of the open Blender session — blender_end first"; + // A number too large for a float arrives as Infinity; written into a transform it is saved as one. + if ((position ?? []).Concat(offset ?? []).Any(v => !float.IsFinite(v))) return "position and offset must be finite numbers"; Vector3 delta = Vector3.Zero; if (position != null) delta = new Vector3(position[0], position[1], position[2]) - frame.WorldTransform.Translation; if (offset != null) delta += new Vector3(offset[0], offset[1], offset[2]); @@ -463,6 +476,7 @@ public CameraInfo Camera() 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 (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); From 7750e77710489a6d628fbdddbac20d940f628f89 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:40:10 +0300 Subject: [PATCH 059/102] fix(mirror): a second look at 3696630 and a3b5f73 Read again before they went up. A material block worn by two objects was settled twice. The mirror writes winter's hashes into the block, so the second wearer was resolved from them as if they were summer's - counted as a slot the modder had re-pointed, or substituted again where a winter hash is also a summer one. The wearers of each block are gathered first and the block is settled once, from summer's hashes, by whichever wearer the pair has an answer for. A mesh that existed only in winter was lost without a word. 'Dropped' had become the shipped pair's meshes summer no longer has; it is what the mirror takes out of winter - every winter mesh the mirrored scene has no counterpart for. A refreshed texture kept its old manifest entry. A repaint at another size changes whether the texture has a MIP companion, which its entry states (HasMIP); the bytes were brought up to date under an entry that promised a companion no longer there, or hid one that now was. The entry is compared with summer's and replaced with it. The material list's tiles draw a blended material blended, as the preview beside them does since a3b5f73. And the add-on's push signature includes the colour space of an alpha mask: an 8-bit mask is linearised or not by its tag, so retagging it changes what is sent while name, file and size stay the same - and nothing was resent. --probe-season-mirror uppertown 28/28 (5 new: a mesh only in winter is reported as dropped; a block two objects wear, the wearer that never wore the summer material first in the file; a refreshed texture's entry follows a changed HasMIP). --probe-bridge-material 68/68, --probe-material-editor 65/65. The add-on change is compiled, not run in Blender. --- src/Illusion.Assets/Sds/SeasonMirror.cs | 73 ++++++++++++++----- .../addons/illusion_bridge/exporter.py | 6 +- .../Diagnostics/Probes/SeasonMirrorProbes.cs | 64 ++++++++++++++++ .../Viewport/MaterialThumbnailRenderer.cs | 9 ++- 4 files changed, 131 insertions(+), 21 deletions(-) diff --git a/src/Illusion.Assets/Sds/SeasonMirror.cs b/src/Illusion.Assets/Sds/SeasonMirror.cs index 842ad11..68a163b 100644 --- a/src/Illusion.Assets/Sds/SeasonMirror.cs +++ b/src/Illusion.Assets/Sds/SeasonMirror.cs @@ -46,7 +46,8 @@ public static class SeasonMirror /// Objects every one of whose materials was settled: winter's own where the season /// changes it, summer's where it was deliberately changed. /// Summer objects under a name the shipped pair does not have — they arrive as they are. - /// Meshes the shipped pair has and summer no longer does — gone from winter too. + /// Meshes the winter scene held that the mirrored scene does not: ones deleted in + /// summer, and ones that were only ever in winter — both are gone from winter after the mirror. /// Material slots the modder pointed at another material in summer; the new /// material is carried into winter rather than overwritten with the old winter one. /// Objects that could not be told apart from a namesake wearing the same summer @@ -377,8 +378,15 @@ private static List PayloadsOf(SdsArchive archive, string type) if (MaterialOf(frame) != null) namesakes[frame.Name.Hash] = namesakes.GetValueOrDefault(frame.Name.Hash) + 1; } - int matched = 0, added = 0, reassigned = 0, ambiguous = 0, known = 0; + int matched = 0, added = 0, reassigned = 0, ambiguous = 0; var seen = new Dictionary(); + // A material block can be worn by more than one object, and the hashes are rewritten IN the block. + // Settled object by object, the second wearer was resolved from winter's hashes as if they were + // summer's — counted as re-pointed by the modder, or, where a winter hash is also some summer one, + // substituted a second time. So the wearers of each block are gathered first, and the block is then + // settled once, from summer's hashes, by whichever of its wearers the pair has an answer for. + var wearers = new Dictionary>(ReferenceEqualityComparer.Instance); + var blocks = new List(); foreach (FrameObjectBase frame in scene.FrameObjects.Values.OfType()) { if (MaterialOf(frame) is not { } ours) continue; @@ -389,29 +397,43 @@ private static List PayloadsOf(SdsArchive archive, string type) added++; continue; } - known++; + if (!wearers.TryGetValue(ours, out List<(ulong, int)>? worn)) + { + wearers[ours] = worn = []; + blocks.Add(ours); + } + worn.Add((name, occurrence)); + } + foreach (FrameMaterial block in blocks) + { bool unsure = false; - for (int lod = 0; lod < ours.Materials.Count; lod++) + for (int lod = 0; lod < block.Materials.Count; lod++) { - for (int slot = 0; slot < ours.Materials[lod].Length; slot++) + for (int slot = 0; slot < block.Materials[lod].Length; slot++) { - MaterialStruct material = ours.Materials[lod][slot]; - switch (pair.Resolve(name, occurrence, namesakes[name], lod, slot, material.MaterialHash, out ulong winter)) + MaterialStruct material = block.Materials[lod][slot]; + ulong summerHash = material.MaterialHash; + bool seasonal = false, doubt = false; + foreach ((ulong name, int occurrence) in wearers[block]) { - case SeasonPair.Answer.Seasonal: + SeasonPair.Answer answer = + pair.Resolve(name, occurrence, namesakes[name], lod, slot, summerHash, out ulong winter); + if (answer == SeasonPair.Answer.Seasonal) + { material.MaterialHash = winter; + seasonal = true; break; - case SeasonPair.Answer.Reassigned: - reassigned++; - break; - default: - unsure = true; - break; + } + doubt |= answer == SeasonPair.Answer.Ambiguous; } + if (seasonal) continue; + if (doubt) unsure = true; + else reassigned++; } } - if (unsure) ambiguous++; else matched++; + if (unsure) ambiguous += wearers[block].Count; + else matched += wearers[block].Count; } // Two archives that merely share a few names are not a seasonal pair, and writing one over the other @@ -499,7 +521,12 @@ private static List PayloadsOf(SdsArchive archive, string type) if (wrote) textures.Add(texture); } - return new Report(matched, added, pair.Meshes - kept, reassigned, ambiguous, files, textures); + // What this mirror took out of winter: every mesh the winter scene held, name by name, beyond what + // the mirrored scene has under that name — whether the pair shipped with it or it was put into + // winter alone. Counted against the shipped pair it missed the second kind, and a mesh that existed + // only in winter vanished with "0 dropped". + int dropped = standing.Sum(n => Math.Max(0, n.Value - namesakes.GetValueOrDefault(n.Key))); + return new Report(matched, added, dropped, reassigned, ambiguous, files, textures); // Adds a summer file winter lacks, or refreshes one an earlier mirror brought. True when it wrote. bool Bring(string name) @@ -511,7 +538,19 @@ bool Bring(string name) string target = Path.Combine(winterDir, name.TrimStart('/', '\\')); if (!File.Exists(source)) return false; byte[] bytes = File.ReadAllBytes(source); - if (File.Exists(target) && File.ReadAllBytes(target).AsSpan().SequenceEqual(bytes)) return false; + bool sameBytes = File.Exists(target) && File.ReadAllBytes(target).AsSpan().SequenceEqual(bytes); + // The entry as well as the bytes. A texture's entry says whether a MIP companion goes with it + // (HasMIP), and a repaint at another size changes that: the picture refreshed under the entry of + // the old one is an entry promising a companion that is no longer there — a chain the game + // streams and does not find — or one hiding a companion that now is. + IReadOnlyList<(string Name, string Value)>? theirs = from.Manifest.EntryFields(name), ours = manifest.EntryFields(name); + bool sameEntry = theirs != null && ours != null && theirs.SequenceEqual(ours); + if (sameBytes && sameEntry) return false; + if (!sameEntry && theirs != null) + { + manifest.RemoveEntry(name); + return CopyEntry(from.Manifest, manifest, summerDir, winterDir, name); + } AtomicFile.WriteAllBytes(target, bytes); return true; } diff --git a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py index 58333b7..4482ba1 100644 --- a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py +++ b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/exporter.py @@ -330,7 +330,11 @@ def _describe_authored(material, entry, blocks, image_refs, signatures): mode, mask, channel, value = alpha if mask is not None and mask != diffuse: parts.append(materials.image_signature(mask)) - alpha_key = f"alpha={mode}:{channel}:{value}:{mask.name if mask is not None else ''}" + # …and the colour space the mask is read in: an 8-bit mask tagged sRGB is linearised on the + # way out and one tagged Non-Color is not, so retagging it changes what is sent while the + # file, its name and its size stay what they were. + space = getattr(getattr(mask, "colorspace_settings", None), "name", "") if mask is not None else "" + alpha_key = f"alpha={mode}:{channel}:{value}:{mask.name if mask is not None else ''}:{space}" values.append(alpha_key) signature = None if any(p is None for p in parts) else "|".join(parts + values) if game_hash and signature is not None and signature == material.get(materials.SIGNATURE_PROP): diff --git a/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs b/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs index e079f67..9214523 100644 --- a/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs +++ b/src/Illusion/Diagnostics/Probes/SeasonMirrorProbes.cs @@ -135,6 +135,52 @@ void Check(string name, bool ok, string detail = "") Check("and winter is whole again, byte for byte the scene it was", File.ReadAllBytes(winterScene).AsSpan().SequenceEqual(resaved)); + // ── A mesh that is ONLY in winter goes with the mirror — and has to be counted ── + { + FrameResource lone = ExtractedSds.Load(winter).FrameResource!; + FrameObjectSingleMesh only = lone.FrameObjects.Values.OfType() + .Last(m => m.GetType() == typeof(FrameObjectSingleMesh) && m.Refs.ContainsKey(FrameEntryRefTypes.Material) + && lone.FrameObjects.Values.OfType().Count(o => o.Name.Hash == m.Name.Hash) == 1); + only.Name = new Formats.Hashing.HashName("illusion_probe_only_in_winter"); + AtomicFile.WriteAllBytes(winterScene, lone.WriteToStream()); + SeasonMirror.Report? swept = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out reason); + Check("a mesh that was only in winter is reported as dropped, not lost without a word", + swept is { Dropped: 1, Added: 0 }, swept == null ? reason ?? "" : $"{swept.Dropped} dropped"); + Check("…and winter is the mirrored scene again", File.ReadAllBytes(winterScene).AsSpan().SequenceEqual(resaved)); + } + + // ── Two objects wearing ONE material block: the block is settled once ── + // The hashes are rewritten in the block, so the second wearer used to be resolved from winter's + // hashes as if they were summer's — and counted as a slot the modder had re-pointed. + { + FrameResource sharing = ExtractedSds.Load(summer).FrameResource!; + List frames = [.. sharing.FrameObjects.Values.OfType()]; + // One of the dressed meshes owns the block (S1 in summer, W1 in winter); a stock mesh of + // another name, which never wore S1, is put on it — whichever of the two comes first in the file. + const ulong ownerWinter = W1; + var owner = (FrameObjectSingleMesh)frames[groups[0][1]]; + FrameObjectSingleMesh? guest = frames.OfType().FirstOrDefault( + m => m.GetType() == typeof(FrameObjectSingleMesh) && m.Refs.ContainsKey(FrameEntryRefTypes.Material) + && m.Name.Hash != owner.Name.Hash && !groups.SelectMany(g => g).Contains(frames.IndexOf(m))); + Check("a second mesh was found to put on the first one's material block", guest != null); + if (guest != null) + { + ulong guestName = guest.Name.Hash; + guest.Material = owner.Material; + guest.ReplaceRef(FrameEntryRefTypes.Material, owner.Material.RefID); + AtomicFile.WriteAllBytes(summerScene, sharing.WriteToStream()); + SeasonMirror.Report? shared = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out reason); + ulong worn = ExtractedSds.Load(winter).FrameResource!.FrameObjects.Values.OfType() + .Where(m => m.Name.Hash == guestName && m.Refs.ContainsKey(FrameEntryRefTypes.Material)) + .Select(m => m.Material.Materials[0][0].MaterialHash).FirstOrDefault(); + Check("a block two objects wear is turned to winter once: nothing counted as re-pointed, both in its winter material", + shared is { Reassigned: 0, Ambiguous: 0 } && worn == ownerWinter, + shared == null ? reason ?? "" : $"{shared.Reassigned} re-pointed, the second wearer in 0x{worn:X16}"); + AtomicFile.WriteAllBytes(summerScene, summerAsShipped); + SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out _); + } + } + // ── Review of #5, P1: equal size is not "the same scene" ── // One object of the winter archive stands 50 units away. Nothing about the file's size or its // pools changes; the pair must still be refused, or the mirror would put it back where summer has it. @@ -242,6 +288,24 @@ ulong SlotOf(ulong name) => result.FrameObjects.Values.OfType asListed = listing.EntryFields(texture)!; + string flipped = asListed.FirstOrDefault(f => f.Name == "HasMIP").Value == "1" ? "0" : "1"; + listing.RemoveEntry(texture); + listing.AddEntry("Texture", texture, int.Parse(asListed[^1].Value, System.Globalization.CultureInfo.InvariantCulture), + [("HasMIP", flipped)]); + SeasonMirror.Report? relisted = authoredPair == null ? null + : SeasonMirror.Mirror(summer, winter, "summer", "winter", authoredPair, out reason); + IReadOnlyList<(string Name, string Value)>? inWinterNow = SdsManifest.Load(winter).EntryFields(texture); + Check("…and its manifest entry follows when the repaint changed whether it has a MIP companion", + relisted != null && inWinterNow != null && inWinterNow.SequenceEqual(SdsManifest.Load(summer).EntryFields(texture)!) + && inWinterNow.Any(f => f.Name == "HasMIP" && f.Value == flipped), + relisted == null ? reason ?? "no pair" : string.Join(" ", (inWinterNow ?? []).Select(f => $"{f.Name}={f.Value}"))); + File.WriteAllBytes(Path.Combine(summer, "SDSContent.xml"), summerListing); + File.WriteAllBytes(Path.Combine(winter, texture), painted); SeasonMirror.Report? kept = SeasonMirror.Mirror(summer, winter, "summer", "winter", pair, out reason); Check("a texture winter shipped with is left as winter has it", diff --git a/src/Illusion/Viewport/MaterialThumbnailRenderer.cs b/src/Illusion/Viewport/MaterialThumbnailRenderer.cs index 71fa660..170f2ce 100644 --- a/src/Illusion/Viewport/MaterialThumbnailRenderer.cs +++ b/src/Illusion/Viewport/MaterialThumbnailRenderer.cs @@ -40,15 +40,18 @@ internal sealed class MaterialThumbnailRenderer : IDisposable // D013/D027 feed the preview lighting and C002 the paint — a parameter edit must re-render, so they // join the key. Without the tint in it, recolouring a material would leave its tile on the old colour. LightingConstants lighting = MaterialPreviewViewport.LightingFor(info); - Vector4 tint = Assets.MafiaMaterials.GetMaterialTextures(info.Hash).Tint; - string key = $"{info.Hash:X}|{diffuse}|{normal}|{specular}|{folders.Count}|{lighting.SpecParams}|{tint}"; + // …and so is whether its alpha is blended or tested: the tile of a pane of glass at 0.3 was cut out + // against one half — an empty tile — beside a preview that showed it translucent. + Assets.MafiaMaterials.MaterialTextures look = Assets.MafiaMaterials.GetMaterialTextures(info.Hash); + Vector4 tint = look.Tint; + string key = $"{info.Hash:X}|{diffuse}|{normal}|{specular}|{folders.Count}|{lighting.SpecParams}|{tint}|{look.Blended}"; if (_cache.TryGetValue(key, out ImageSource? hit)) return hit; if (!EnsureContext()) return null; foreach (string folder in folders) _renderer!.Textures.AddFolder(folder); _renderer!.Clear(); - _renderer.AddMesh(SphereMesh.Create(new MeshPart(0, 0, diffuse, normal, specular, info.Hash, tint))); + _renderer.AddMesh(SphereMesh.Create(new MeshPart(0, 0, diffuse, normal, specular, info.Hash, tint, look.Blended))); _renderer.Lighting = lighting; _renderer.Render(_target!); From d880b766c0bdb64f03e366a410fc3aba2a78ae3a Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:47:04 +0300 Subject: [PATCH 060/102] fix(import): a third pass over the collision links, the sweep and the Props tab Read again before it went up. A hull changed on its own lost its tie. Resized by itself it is re-cooked under a new hash, and only a resize of the OBJECT rewrote the record; the next move or delete of the object left the hull behind - or took some other placement of the old hull that stood near. Moved by itself past five metres it stopped being found. The record now follows a placement through whatever is done to it: hash and place are brought up to date in the same undo step, whether the placement was changed with its object or alone. Two ways of moving an object did not carry its collision: a transform pushed from Blender, and a move of a frame the object had been parented to. Both go through the same carrying as a typed move now (CarryLinked), and what was given to anything UNDER a node counts as riding with it - for a drag, a typed move, a push and a delete alike. One description of which placement is whose (Assignments) serves all of them. Renaming an imported object onto a name that already had a record wrote over that record, and the undo then took the remainder away from the other object as well. A record is never moved over another. Parking a swept texture could lose it: with the manifest entry dropped and the file's removal failing, nothing was on record, and the next sweep deleted the file for good. Both files are copied aside and written down first; whatever fails after that, they are on record. The sweep itself - housekeeping in the middle of a save - no longer lets an unreadable manifest end the save between the scene and its buffer pools. And a redo of an import puts its parked textures back at once, not at the next save. The Props tab could stop paging at the very end of the list: a page held back by the one-per-gesture rule was never asked for again, since no scroll event comes when the view is already at the bottom. It is looked at again a moment later. Reading a prop's archive no longer holds the lock the UI thread takes when the tab is left. --probe-object-import-live 44/44 (8 new: a hull resized alone and its undo; a hull moved fourteen metres alone still goes with its object; a transform pushed through the push batch carries the hull and undoes; an object renamed onto another's name leaves that record alone, before and after undo). --probe-object-transplant 55/55 (a redo returns parked textures). --probe-prop-catalog 12/12, --probe-season-mirror 28/28. Not checked: a move of a parent frame with an imported object under it; the paging retry and the tab being left during a read, in a window. --- src/Illusion.Assets/Sds/ArchiveCarry.cs | 74 +++++- src/Illusion.Assets/Sds/ImportLinks.cs | 6 +- .../Probes/ObjectImportLiveProbes.cs | 67 ++++++ .../Probes/ObjectTransplantProbes.cs | 8 + src/Illusion/Viewport/D3DImageHost.cs | 226 +++++++++++------- .../Viewport/GeometryEditController.cs | 14 ++ .../Viewport/ObjectImportController.cs | 9 +- .../Viewport/PropThumbnailRenderer.cs | 26 +- .../Viewport/TransformEditController.cs | 137 ++++++----- .../ScenePanel/Tabs/PropsTabView.xaml.cs | 51 +++- 10 files changed, 450 insertions(+), 168 deletions(-) diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index 0167eae..37eeb4b 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -361,25 +361,38 @@ private static void WriteParked(string dir, Dictionary> AtomicFile.WriteAllBytes(Path.Combine(folder, ParkedIndex), System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(parked)); } - // Out of the manifest and into the parked folder. The copy is made BEFORE the entry is dropped and the - // original removed after it: whichever step fails, the manifest never names a file that is not there - // (which fails a Build), and the texture is never in neither place. + // Out of the manifest and into the parked folder, in two steps. First BOTH files are copied aside and + // written down; a failure there leaves the texture exactly as it was (it stays carried). Only then are + // the entries dropped and the originals removed — and whatever fails in that half, what was copied is + // still returned, so it is on record as parked: an entry already gone with its file not recorded was a + // texture the next sweep deleted for good. private static List Park(SdsManifest manifest, string dir, string texture, List dropped) { var files = new List(); - foreach (string file in new[] { texture, SdsImportTypes.MipNameFor(texture) }) + string[] both = [texture, SdsImportTypes.MipNameFor(texture)]; + foreach (string file in both) { string path = Path.Combine(dir, file); IReadOnlyList<(string Name, string Value)>? fields = manifest.EntryFields(file); - if (fields != null && File.Exists(path)) + if (fields == null || !File.Exists(path)) continue; + string parkedPath = ParkedPath(dir, file); + Directory.CreateDirectory(Path.GetDirectoryName(parkedPath)!); + File.Copy(path, parkedPath, overwrite: true); + files.Add(new ParkedFile { File = file, Fields = [.. fields.Select(f => new[] { f.Name, f.Value })] }); + } + + foreach (string file in both) + { + try { - string parkedPath = ParkedPath(dir, file); - Directory.CreateDirectory(Path.GetDirectoryName(parkedPath)!); - File.Copy(path, parkedPath, overwrite: true); - files.Add(new ParkedFile { File = file, Fields = [.. fields.Select(f => new[] { f.Name, f.Value })] }); + if (manifest.RemoveEntry(file)) dropped.Add(file); + File.Delete(Path.Combine(dir, file)); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // Still listed, or listed no more and its file still there: either way the copy is safe and + // on record, and the putting back sorts it out (a file still listed is simply left). } - if (manifest.RemoveEntry(file)) dropped.Add(file); - File.Delete(path); } return files; } @@ -431,6 +444,41 @@ public static IReadOnlyList SweepUnused(string extracted, Formats.Frames { ArgumentException.ThrowIfNullOrEmpty(extracted); ArgumentNullException.ThrowIfNull(scene); + try + { + return Sweep(extracted, scene, park: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Formats.SdsFormatException + or System.Xml.XmlException) + { + // Housekeeping, run in the middle of a save — after the scene is written and before its buffer + // pools are. A manifest that cannot be read just now must not leave the save half done. + return []; + } + } + + /// + /// Puts back the parked textures the scene names again, and nothing else — for the moment an import is + /// redone, so that the working copy has the object's textures as soon as the object is back rather than + /// only after the next save. + /// + public static void ReturnParked(string extracted, Formats.Frames.FrameResource scene) + { + ArgumentException.ThrowIfNullOrEmpty(extracted); + ArgumentNullException.ThrowIfNull(scene); + try + { + Sweep(extracted, scene, park: false); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or Formats.SdsFormatException + or System.Xml.XmlException) + { + // then they come back with the next save + } + } + + private static IReadOnlyList Sweep(string extracted, Formats.Frames.FrameResource scene, bool park) + { HashSet carried = ReadRegister(extracted); Dictionary> parked = ReadParked(extracted); if (carried.Count == 0 && parked.Count == 0) return []; @@ -475,7 +523,7 @@ public static IReadOnlyList SweepUnused(string extracted, Formats.Frames carried.Add(texture); TextureSearchIndex.Register(Path.Combine(extracted, texture)); } - else if (ParkedHere.Contains(ParkedKey(extracted, texture))) + else if (!park || ParkedHere.Contains(ParkedKey(extracted, texture))) { continue; // an undo stack of this run can still bring its object back } @@ -492,7 +540,7 @@ public static IReadOnlyList SweepUnused(string extracted, Formats.Frames } } - foreach (string texture in carried.ToList()) + foreach (string texture in park ? carried.ToList() : []) { if (named.Contains(texture)) continue; try diff --git a/src/Illusion.Assets/Sds/ImportLinks.cs b/src/Illusion.Assets/Sds/ImportLinks.cs index ce66941..128c39f 100644 --- a/src/Illusion.Assets/Sds/ImportLinks.cs +++ b/src/Illusion.Assets/Sds/ImportLinks.cs @@ -51,7 +51,11 @@ public static void Rename(string extractedDir, string oldName, string newName) ArgumentException.ThrowIfNullOrEmpty(extractedDir); if (string.IsNullOrEmpty(oldName) || string.IsNullOrEmpty(newName) || oldName == newName) return; Dictionary> links = Read(extractedDir); - if (!links.Remove(oldName, out List? hulls)) return; + // Not over another object's record. Renamed onto a name that has one, this object gives up its own + // tie (two objects of one name cannot both be found by it) — but the other's stays, and so does this + // one's entry under the old name, which is what the undo of the rename finds and leaves alone. Moved + // over it, the other object's record was gone, and the undo then took what was left away from it too. + if (links.ContainsKey(newName) || !links.Remove(oldName, out List? hulls)) return; links[newName] = hulls; Write(extractedDir, links); } diff --git a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs index b1d40df..319a58f 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs @@ -178,6 +178,54 @@ Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) placed.Instance.Hash == hashBefore && ImportLinks.HullsOf(dir, "probe_live") is [{ } then] && then.Hull == hashBefore && host.LinkedCollisionNodes(node).Count == 1); + // ── the hull resized ON ITS OWN: nothing rides with it, and the object's record still follows ── + host.Selection.SetSelection([hulls[0]], hulls[0]); + Vector3 hullPivot = hull.WorldTransform.Translation; + host.GizmoBeginDrag(Rendering.Gizmos.GizmoMode.Scale); + host.GizmoApplyWorldDelta(Matrix4x4.CreateTranslation(-hullPivot) * Matrix4x4.CreateScale(1.3f) * Matrix4x4.CreateTranslation(hullPivot)); + host.GizmoEndDrag(); + Check("a hull resized on its own is re-cooked under a new hash, and stays the object's", + placed.Instance.Hash != hashBefore && ImportLinks.HullsOf(dir, "probe_live") is [{ } alone] && alone.Hull == placed.Instance.Hash + && host.LinkedCollisionNodes(node).Count == 1, $"{hashBefore:x16} → {placed.Instance.Hash:x16}"); + host.Undo(); + Check("…and undoing that puts hull and record back", placed.Instance.Hash == hashBefore + && ImportLinks.HullsOf(dir, "probe_live") is [{ } restored] && restored.Hull == hashBefore); + + // ── the hull moved on its own, well past where it would be looked for: it is still the object's ── + host.Selection.SetSelection([hulls[0]], hulls[0]); + host.GizmoBeginDrag(Rendering.Gizmos.GizmoMode.Move); + host.GizmoApplyWorldDelta(Matrix4x4.CreateTranslation(0f, -14f, 0f)); + host.GizmoEndDrag(); + Vector3 strayedTo = hull.WorldTransform.Translation, objectStood = moved.WorldTransform.Translation; + bool stillLinked = host.LinkedCollisionNodes(node).Count == 1; + host.Selection.SetSelection([node], node); + host.GizmoBeginDrag(Rendering.Gizmos.GizmoMode.Move); + host.GizmoApplyWorldDelta(Matrix4x4.CreateTranslation(3f, 0f, 0f)); + host.GizmoEndDrag(); + Check("a hull moved fourteen metres on its own is still the object's, and goes with it from where it was put", + stillLinked && ProbeAssert.Approx(hull.WorldTransform.Translation - strayedTo, new Vector3(3f, 0f, 0f), 0.01f) + && ProbeAssert.Approx(moved.WorldTransform.Translation - objectStood, new Vector3(3f, 0f, 0f), 0.01f), + $"linked after its own move: {stillLinked}"); + host.Undo(); + host.Undo(); + Check("…and with both moves undone it is where it was, the record as it was", + host.LinkedCollisionNodes(node).Count == 1 && ImportLinks.HullsOf(dir, "probe_live") is [{ } asWas] + && asWas.At is { } placeAsWas && Vector3.Distance(placeAsWas, Relative()) < 0.02f); + + // ── a transform pushed from Blender takes the hull along, as one typed or dragged does ── + { + Vector3 hullWas = hull.WorldTransform.Translation; + Matrix4x4 pushedFrom = moved.LocalTransform, pushedTo = pushedFrom; + pushedTo.Translation += new Vector3(-6f, 2f, 0f); + host.GeometryEditing.ApplyPushBatch([], [new GeometryEditController.TransformItem(node, pushedFrom, pushedTo)], [], null); + Check("a transform pushed from Blender carries the hull", + ProbeAssert.Approx(hull.WorldTransform.Translation - hullWas, new Vector3(-6f, 2f, 0f), 0.01f) + && host.LinkedCollisionNodes(node).Count == 1); + host.Undo(); + Check("…and its undo brings both back", ProbeAssert.Approx(hull.WorldTransform.Translation, hullWas, 0.01f) + && ProbeAssert.Approx(moved.LocalTransform.Translation, pushedFrom.Translation, 0.01f)); + } + // ── the same hull given twice, the second placement nine metres from the object's pivot ── int given = 1; host.Selection.SetSelection([hulls[0]], hulls[0]); @@ -223,6 +271,25 @@ Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) ImportLinks.HullsOf(dir, "probe_live").Count == given && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 0); 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 _); + if (why == null) + { + IReadOnlyList mine = [.. ImportLinks.HullsOf(dir, "probe_live_renamed")]; + string? onto = session.SetProperty("probe_live_b", "Base.Name", "probe_live_renamed"); + Check("renamed onto a name that has a record, the other object's record stays what it was", + onto == null && ImportLinks.HullsOf(dir, "probe_live_renamed").SequenceEqual(mine) + && host.LinkedCollisionNodes(node).Count == given, onto ?? ""); + host.Undo(); + Check("…and after the undo both objects have their own records", + ImportLinks.HullsOf(dir, "probe_live_renamed").SequenceEqual(mine) && ImportLinks.HullsOf(dir, "probe_live_b").Count == 1); + host.Undo(); // the second import + } + else + { + sb.AppendLine(" (a second copy could not be imported — the rename-onto step was skipped: " + why + ")"); + } + // ── delete takes the hull; undo brings both back ── host.Selection.SetSelection([node], node); host.DeleteSelected(); diff --git a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs index f78169e..85e95a8 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectTransplantProbes.cs @@ -350,6 +350,14 @@ void PutBack() Check("and the carried register knows them again, so a later undo sweeps them again", Sweeps(TakeOut, PutBack, dir, ours, brought, Gone, InUse)); + // The redo itself puts them back — not only the save after it. + TakeOut(); + ArchiveCarry.SweepUnused(dir, ours); + PutBack(); + ArchiveCarry.ReturnParked(dir, ours); + Check("a redo returns the textures a save had set aside, without waiting for the next save", + brought.All(InUse) && !brought.Any(Parked) && Sweeps(TakeOut, PutBack, dir, ours, brought, Gone, InUse)); + // Brought a second time while parked — the same object imported again after the undo. TakeOut(); ArchiveCarry.SweepUnused(dir, ours); diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index f5be3a0..1068065 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -316,16 +316,25 @@ internal Dictionary> LinkedCollision(IEnumerable? standingAt = null) => LinkedSlots(nodes, standingAt).ToDictionary(pair => pair.Key, pair => pair.Value.Select(slot => slot.Hull).ToList()); - /// The same, with which entry of the object's record each placement answers to — for a caller - /// that has to rewrite the record (a scale re-cooks a hull under a new hash). - internal Dictionary> LinkedSlots(IEnumerable nodes, + /// One placement an imported object was given, as the scene has it now: whose it is, which entry + /// of that object's record it answers to, and its node. + internal sealed record LinkedPlacement( + string Dir, string Name, Formats.Frames.ObjectTypes.FrameObjectBase Owner, int Slot, SceneNode Hull); + + /// + /// The same, with whose each placement is and which entry of the record it answers to — and counting as + /// a node's not only what was given to it but what was given to anything UNDER it: an imported object + /// that was parented to another frame moves when that frame moves, and its collision has to move with it. + /// + internal Dictionary> LinkedSlots(IEnumerable nodes, IReadOnlyDictionary? standingAt = null) { - var result = new Dictionary>(); + var result = new Dictionary>(); var asked = new List<(SceneNode Node, FrameNodeAdapter Frame, SceneDocumentAdapter Scene, SceneNode Document)>(); foreach (SceneNode node in nodes) { - if (node.Source is FrameNodeAdapter frame && node.OwningDocumentNode() is { Source: SceneDocumentAdapter scene } documentNode) + if (node.Source is FrameNodeAdapter frame && node.Source is not CollisionInstanceAdapter + && node.OwningDocumentNode() is { Source: SceneDocumentAdapter scene } documentNode) { asked.Add((node, frame, scene, documentNode)); } @@ -333,103 +342,150 @@ internal Dictionary> LinkedCollision(IEnumerable a.Scene)) { - SceneDocumentAdapter scene = archive.Key; - string dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); - IReadOnlyDictionary> links = Assets.Sds.ImportLinks.All(dir); - if (links.Count == 0 || FindCollisionLayer(archive.First().Document) is not { } layer) continue; - - // The object a recorded name belongs to: the first of that name, as the link was made for. - var owners = new Dictionary(StringComparer.Ordinal); - foreach (object value in scene.Frame.FrameObjects.Values) + // Where a node is to be looked from, when not from where it stands: everything under it shifts + // the same way. + var shifts = new List<(Formats.Frames.ObjectTypes.FrameObjectBase Root, Matrix4x4 Shift)>(); + foreach (var a in archive) { - if (value is Formats.Frames.ObjectTypes.FrameObjectBase f && f.Name.String is { Length: > 0 } n && links.ContainsKey(n)) + if (standingAt != null && standingAt.TryGetValue(a.Node, out Matrix4x4 at) + && Matrix4x4.Invert(a.Frame.Frame.WorldTransform, out Matrix4x4 back)) { - owners.TryAdd(n, f); + shifts.Add((a.Frame.Frame, back * at)); } } - var looksFrom = new Dictionary(); + List all = Assignments(archive.Key, archive.First().Document, owner => + { + foreach ((Formats.Frames.ObjectTypes.FrameObjectBase root, Matrix4x4 shift) in shifts) + { + if (IsSelfOrUnder(owner, root)) return owner.WorldTransform * shift; + } + return null; + }); foreach (var a in archive) { - if (standingAt != null && standingAt.TryGetValue(a.Node, out Matrix4x4 at)) looksFrom[a.Frame.Frame] = at; + List mine = [.. all.Where(p => IsSelfOrUnder(p.Owner, a.Frame.Frame))]; + if (mine.Count > 0) result[a.Node] = mine; } + } + return result; + } - List placements = [.. layer.Children]; - var byHull = new Dictionary>(); - for (int i = 0; i < placements.Count; i++) + /// + /// For collision placements themselves: which imported object each was given to, if any. For a caller + /// about to change a placement on its own — move it, resize it — and bound to keep its object's record true. + /// + internal List LinksOf(IEnumerable placements) + { + var found = new List(); + foreach (var layer in placements.Where(n => n.Source is CollisionInstanceAdapter && n.Parent != null).GroupBy(n => n.Parent!)) + { + // The scene the layer hangs beside: the frame document under the same wrapper. + SceneNode? documentNode = null; + void Walk(SceneNode node) { - if (placements[i].Source is not CollisionInstanceAdapter ci) continue; - if (!byHull.TryGetValue(ci.Instance.Hash, out List? of)) byHull[ci.Instance.Hash] = of = []; - of.Add(i); + if (documentNode != null) return; + if (node.Source is SceneDocumentAdapter) { documentNode = node; return; } + foreach (SceneNode child in node.Children) Walk(child); } + Walk(layer.Key.Parent ?? layer.Key); + if (documentNode?.Source is not SceneDocumentAdapter scene) continue; + var wanted = new HashSet(layer); + found.AddRange(Assignments(scene, documentNode, _ => null).Where(p => wanted.Contains(p.Hull))); + } + return found; + } + + private static bool IsSelfOrUnder(Formats.Frames.ObjectTypes.FrameObjectBase frame, Formats.Frames.ObjectTypes.FrameObjectBase root) + { + int guard = 0; + for (Formats.Frames.ObjectTypes.FrameObjectBase? at = frame; at != null && guard++ < 256; at = at.Parent) + { + if (ReferenceEquals(at, root)) return true; + } + return false; + } - // Every linked object of the archive bids for the placements of its hulls, nearest first, and a - // placement goes to the first bid only — so two entries for the same hull take two placements. A - // tie — a copy on top of its original — goes to the name that sorts first, so the answer does not - // change from one call to the next. - var bids = new List<(float Distance, string Object, int Slot, int Placement)>(); - foreach ((string linked, List hulls) in links) + // Every placement of an archive that some imported object's record claims, worked out once: each recorded + // entry bids for the placements of its hull, nearest first, and a placement goes to the first bid only. + private List Assignments(SceneDocumentAdapter scene, SceneNode documentNode, + Func lookFrom) + { + var assigned = new List(); + string dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); + IReadOnlyDictionary> links = Assets.Sds.ImportLinks.All(dir); + if (links.Count == 0 || FindCollisionLayer(documentNode) is not { } layer) return assigned; + + // The object a recorded name belongs to: the first of that name, as the link was made for. + var owners = new Dictionary(StringComparer.Ordinal); + foreach (object value in scene.Frame.FrameObjects.Values) + { + if (value is Formats.Frames.ObjectTypes.FrameObjectBase f && f.Name.String is { Length: > 0 } n && links.ContainsKey(n)) { - if (!owners.TryGetValue(linked, out Formats.Frames.ObjectTypes.FrameObjectBase? owner)) continue; - Matrix4x4? from = looksFrom.TryGetValue(owner, out Matrix4x4 lookedFrom) ? lookedFrom : null; - Matrix4x4 standing = from ?? owner.WorldTransform; - List<(Matrix4x4 World, Matrix4x4 ToLocal, Vector3 Min, Vector3 Max)>? boxes = null; - Vector3 pivot = standing.Translation; - for (int slot = 0; slot < hulls.Count; slot++) + owners.TryAdd(n, f); + } + } + + List placements = [.. layer.Children]; + var byHull = new Dictionary>(); + for (int i = 0; i < placements.Count; i++) + { + if (placements[i].Source is not CollisionInstanceAdapter ci) continue; + if (!byHull.TryGetValue(ci.Instance.Hash, out List? of)) byHull[ci.Instance.Hash] = of = []; + of.Add(i); + } + + // A tie — a copy on top of its original — goes to the name that sorts first, so the answer does not + // change from one call to the next. Two entries for the same hull take two placements. + var bids = new List<(float Distance, string Object, int Slot, int Placement)>(); + foreach ((string linked, List hulls) in links) + { + if (!owners.TryGetValue(linked, out Formats.Frames.ObjectTypes.FrameObjectBase? owner)) continue; + Matrix4x4? from = lookFrom(owner); + Matrix4x4 standing = from ?? owner.WorldTransform; + List<(Matrix4x4 World, Matrix4x4 ToLocal, Vector3 Min, Vector3 Max)>? boxes = null; + Vector3 pivot = standing.Translation; + for (int slot = 0; slot < hulls.Count; slot++) + { + if (!byHull.TryGetValue(hulls[slot].Hull, out List? candidates)) continue; + foreach (int i in candidates) { - if (!byHull.TryGetValue(hulls[slot].Hull, out List? candidates)) continue; - foreach (int i in candidates) + Vector3 at = ((CollisionInstanceAdapter)placements[i].Source!).Instance.Position; + float distance; + if (hulls[slot].At is { } place) { - Vector3 at = ((CollisionInstanceAdapter)placements[i].Source!).Instance.Position; - float distance; - if (hulls[slot].At is { } place) - { - // Where the object's matrix puts the place the placement was recorded at. The two - // move, turn and resize together, so this is where it stands — however far that - // is from the object's pivot or its box. The few metres are for a hull the user - // has since nudged on its own. - distance = Vector3.Distance(Vector3.Transform(place, standing), at); - } - else + // Where the object's matrix puts the place the placement was recorded at. The two + // move, turn and resize together, so this is where it stands — however far that is + // from the object's pivot or its box. + distance = Vector3.Distance(Vector3.Transform(place, standing), at); + } + else + { + // A record from before places were kept: by nearness to the object's box (its pivot + // still counts — nothing that was linked before stops being linked). + boxes ??= BoxesOf(owner, from, out pivot); + distance = Vector3.Distance(pivot, at); + foreach ((Matrix4x4 world, Matrix4x4 toLocal, Vector3 min, Vector3 max) in boxes) { - // A record from before places were kept: by nearness to the object's box (its - // pivot still counts — nothing that was linked before stops being linked). - boxes ??= BoxesOf(owner, from, out pivot); - distance = Vector3.Distance(pivot, at); - foreach ((Matrix4x4 world, Matrix4x4 toLocal, Vector3 min, Vector3 max) in boxes) - { - Vector3 nearest = Vector3.Transform(Vector3.Clamp(Vector3.Transform(at, toLocal), min, max), world); - distance = MathF.Min(distance, Vector3.Distance(nearest, at)); - } + Vector3 nearest = Vector3.Transform(Vector3.Clamp(Vector3.Transform(at, toLocal), min, max), world); + distance = MathF.Min(distance, Vector3.Distance(nearest, at)); } - if (distance < 5f) bids.Add((distance, linked, slot, i)); } + if (distance < 5f) bids.Add((distance, linked, slot, i)); } } + } - var takenPlacements = new HashSet(); - var filledSlots = new HashSet<(string, int)>(); - var found = new Dictionary>(StringComparer.Ordinal); - foreach ((_, string linked, int slot, int placement) in bids - .OrderBy(b => b.Distance).ThenBy(b => b.Object, StringComparer.Ordinal).ThenBy(b => b.Placement)) - { - if (takenPlacements.Contains(placement) || filledSlots.Contains((linked, slot))) continue; - takenPlacements.Add(placement); - filledSlots.Add((linked, slot)); - if (!found.TryGetValue(linked, out List<(int Slot, SceneNode Hull)>? list)) found[linked] = list = []; - list.Add((slot, placements[placement])); - } - - foreach (var a in archive) - { - string name = a.Frame.Frame.Name.String; - if (name is { Length: > 0 } && owners.TryGetValue(name, out Formats.Frames.ObjectTypes.FrameObjectBase? owner) - && ReferenceEquals(owner, a.Frame.Frame) && found.TryGetValue(name, out List<(int Slot, SceneNode Hull)>? hulls)) - { - result[a.Node] = hulls; - } - } + var takenPlacements = new HashSet(); + var filledSlots = new HashSet<(string, int)>(); + foreach ((_, string linked, int slot, int placement) in bids + .OrderBy(b => b.Distance).ThenBy(b => b.Object, StringComparer.Ordinal).ThenBy(b => b.Placement)) + { + if (takenPlacements.Contains(placement) || filledSlots.Contains((linked, slot))) continue; + takenPlacements.Add(placement); + filledSlots.Add((linked, slot)); + assigned.Add(new LinkedPlacement(dir, linked, owners[linked], slot, placements[placement])); } - return result; + return assigned; } /// The record of one placement given to : its hull, and where it stands @@ -502,7 +558,7 @@ public void DuplicateSelected() // selection already held were copied by the collision duplicate and are left alone here. private void DuplicateLinkedCollision(IReadOnlyList selected) { - Dictionary> given = LinkedCollision(Editing.LastDuplicates.Select(d => d.Source)); + Dictionary> given = LinkedSlots(Editing.LastDuplicates.Select(d => d.Source)); foreach ((SceneNode source, SceneNode copy) in Editing.LastDuplicates) { if (copy.Source is not FrameNodeAdapter copyFrame @@ -512,7 +568,11 @@ private void DuplicateLinkedCollision(IReadOnlyList selected) continue; } var hashes = new List(); - foreach (SceneNode hull in given.TryGetValue(source, out List? hulls) ? hulls : []) + // The copy's own — not what was given to things under the source: those are copied with their + // own objects, or not at all. + foreach (SceneNode hull in given.TryGetValue(source, out List? hulls) + ? hulls.Where(p => source.Source is FrameNodeAdapter s && ReferenceEquals(p.Owner, s.Frame)).Select(p => p.Hull) + : []) { if (hull.Source is not CollisionInstanceAdapter original) continue; hashes.Add(LinkFor(copyFrame.Frame, original.Instance)); diff --git a/src/Illusion/Viewport/GeometryEditController.cs b/src/Illusion/Viewport/GeometryEditController.cs index be7eab0..c084085 100644 --- a/src/Illusion/Viewport/GeometryEditController.cs +++ b/src/Illusion/Viewport/GeometryEditController.cs @@ -89,9 +89,23 @@ public List ApplyPushBatch( foreach (TransformItem item in transforms) { + Matrix4x4? parent = item.Node.Source is IFrameNode placed ? placed.ParentWorldTransform : null; ApplyTransform(item.Node, item.After); children.Add(new TransformSubEdit(this, item)); _host.Persistence.MarkFrameModified(item.Node); + + // An object moved in Blender takes the collision it was given along, as one moved in the editor + // does: those placements are tied to it by nothing in the file, and left behind they are an + // obstacle where the object used to stand. + if (parent is not { } parentWorld) continue; + (List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> carried, List applied) = + _host.Editing.CarryLinked(item.Node, item.Before * parentWorld, item.After * parentWorld); + foreach ((SceneNode hull, Matrix4x4 hullBefore, Matrix4x4 hullAfter) in carried) + { + children.Add(new TransformSubEdit(this, new TransformItem(hull, hullBefore, hullAfter))); + _host.Persistence.MarkFrameModified(hull); + } + children.AddRange(applied); } foreach (CreationItem item in creations) diff --git a/src/Illusion/Viewport/ObjectImportController.cs b/src/Illusion/Viewport/ObjectImportController.cs index 471c3ad..6b4c5a6 100644 --- a/src/Illusion/Viewport/ObjectImportController.cs +++ b/src/Illusion/Viewport/ObjectImportController.cs @@ -594,7 +594,14 @@ public void Redo() D3DImageHost host = _owner._host; // The object first — frames, blocks and buffers back in the resource — so the actor below has // something to claim. The first Redo finds it there already, from the transplant that made it. - if (!_carried.IsAttached) _carried.Reattach(); + if (!_carried.IsAttached) + { + _carried.Reattach(); + // A save while the import was undone set its textures aside; they are back with the object, + // not only with the next save. + var scene = (SceneDocumentAdapter)_frameRow.Source!; + ArchiveCarry.ReturnParked(Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive), scene.Frame); + } if (!_rows.Parent.Children.Contains(_rows.Root)) _rows.Parent.AddChild(_rows.Root); var placements = ((SceneDocumentAdapter)_frameRow.Source!).Placements; diff --git a/src/Illusion/Viewport/PropThumbnailRenderer.cs b/src/Illusion/Viewport/PropThumbnailRenderer.cs index ddb7a9a..5370188 100644 --- a/src/Illusion/Viewport/PropThumbnailRenderer.cs +++ b/src/Illusion/Viewport/PropThumbnailRenderer.cs @@ -83,22 +83,24 @@ public Staged Stage(PropEntry entry) { ExtractedSds? held; ActorPlacements? placements; - string dir; + string? dir; lock (_read) { - if (_archive != entry.Archive || _scene == null) + (held, placements, dir) = _archive == entry.Archive ? (_scene, _placements, _archiveDir) : (null, null, null); + } + if (held == null || dir == null) + { + // Read OUTSIDE the lock: it takes a second or more, and the lock is also what the UI thread + // takes to let the held archive go when the tab is left — held through the read, that froze + // the window until the read was done. One prop is staged at a time, so nothing reads twice. + var sds = new FileInfo(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", entry.Archive)); + dir = Assets.MafiaEnvironment.ExtractedDir(sds); + held = ExtractedSds.Load(dir); + placements = held.FrameResource is { } fr ? ActorPlacements.Load(held.Manifest, fr) : null; + lock (_read) { - var sds = new FileInfo(Path.Combine(Assets.MafiaEnvironment.PcFolder, "sds", entry.Archive)); - _archiveDir = Assets.MafiaEnvironment.ExtractedDir(sds); - _scene = null; - ExtractedSds fresh = ExtractedSds.Load(_archiveDir); - _placements = fresh.FrameResource is { } fr ? ActorPlacements.Load(fresh.Manifest, fr) : null; - _scene = fresh; - _archive = entry.Archive; + (_scene, _placements, _archiveDir, _archive) = (held, placements, dir, entry.Archive); } - held = _scene; - placements = _placements; - dir = _archiveDir!; } if (held?.FrameResource is not { } scene || placements == null) return new Staged { Entry = entry }; diff --git a/src/Illusion/Viewport/TransformEditController.cs b/src/Illusion/Viewport/TransformEditController.cs index 3bc9945..de256db 100644 --- a/src/Illusion/Viewport/TransformEditController.cs +++ b/src/Illusion/Viewport/TransformEditController.cs @@ -77,48 +77,101 @@ public void GizmoBeginDrag(GizmoMode mode) // An object carried in from another archive drags the collision it was given along with it: nothing in // the file ties the two, so without this the hull would stay where the object used to stand. var dragged = new HashSet(_dragGroup.Select(g => g.Node)); - _dragLinks = _host.LinkedSlots(dragged.ToList()); - foreach (List<(int Slot, SceneNode Hull)> hulls in _dragLinks.Values) + // The placements the user took hold of themselves, before the ones that only ride along are added: + // changed on their own, they are still their object's, and its record has to be kept true. + _dragLinks = _host.LinksOf(dragged); + foreach (List riding in _host.LinkedSlots(dragged.ToList()).Values) { - foreach ((_, SceneNode hull) in hulls) + foreach (D3DImageHost.LinkedPlacement placement in riding) { - if (hull.Source is IFrameNode h && dragged.Add(hull)) _dragGroup.Add((hull, h.WorldTransform, h.LocalTransform)); + if (placement.Hull.Source is not IFrameNode h || !dragged.Add(placement.Hull)) continue; + _dragGroup.Add((placement.Hull, h.WorldTransform, h.LocalTransform)); + _dragLinks.Add(placement); } } } - // Which linked placements the drag took along, and for which entry of each object's record. - private Dictionary> _dragLinks = new(); + // The linked placements a drag has hold of — riding with their object, or taken on their own. + private List _dragLinks = []; - // A resize ends with the dragged placements re-pointed at freshly cooked hulls — under new hashes. The - // record of what an object was given names the old ones, and unless it is rewritten here the object has - // lost its collision by the next operation: nothing answers to the old hash any more. Rewritten as an - // edit, so that undoing the resize puts the record back with the hulls. - private List RelinkAfterMint(Dictionary> links) + /// + /// Brings the records of imported objects in line with their placements after a change to either. A + /// resize re-cooks a hull under a new hash; a placement moved on its own stands somewhere else in its + /// object's space. The record names both, and unless it follows, the object has lost its collision by the + /// next operation — or, worse, claims some other placement of the old hull that happens to stand near. + /// Rewritten as edits, to go into the same undo step as what caused them. Nothing is written for a + /// placement that only moved together with its object: its place in the object's space is what it was. + /// + internal List Relink(IReadOnlyList links) { var edits = new List(); - foreach ((SceneNode node, List<(int Slot, SceneNode Hull)> slots) in links) + foreach (var record in links.GroupBy(l => (l.Dir, l.Name))) { - if (node.Source is not FrameNodeAdapter frame) continue; - string dir = Assets.MafiaEnvironment.ExtractedDir(frame.Document.SourceArchive); - string name = frame.Frame.Name.String; - List record = [.. Assets.Sds.ImportLinks.HullsOf(dir, name)]; + List entries = [.. Assets.Sds.ImportLinks.HullsOf(record.Key.Dir, record.Key.Name)]; bool changed = false; - foreach ((int slot, SceneNode hull) in slots) + foreach (D3DImageHost.LinkedPlacement link in record) { - if (slot >= record.Count || hull.Source is not CollisionInstanceAdapter placed) continue; - if (record[slot].Hull == placed.Instance.Hash) continue; - record[slot] = record[slot] with { Hull = placed.Instance.Hash }; + if (link.Slot >= entries.Count || link.Hull.Source is not CollisionInstanceAdapter placed) continue; + Assets.Sds.ImportLinks.Link now = D3DImageHost.LinkFor(link.Owner, placed.Instance); + Assets.Sds.ImportLinks.Link was = entries[link.Slot]; + bool samePlace = was.At is not { } before || now.At is not { } after || Vector3.Distance(before, after) < 0.01f; + if (was.Hull == now.Hull && samePlace) continue; + // A record without a place keeps none unless the placement moved on its own — it is matched + // by nearness, and a place written now would be right only until the next change. + entries[link.Slot] = samePlace ? was with { Hull = now.Hull } : now; changed = true; } if (!changed) continue; - var edit = new ImportLinkEdit(dir, name, record); + var edit = new ImportLinkEdit(record.Key.Dir, record.Key.Name, entries); edit.Redo(); edits.Add(edit); } return edits; } + /// + /// Moves the collision an imported object was given — and what was given to anything under it — by the + /// change that took the object from to , for + /// the ways of moving an object that are not a drag: a number typed into the panel, a transform pushed + /// from Blender. The placements are looked for from where the object WAS. Returns what moved, for the + /// caller's undo entry, and the edits already applied alongside (re-cooked hulls, the records). + /// + internal (List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> Moved, List Applied) CarryLinked( + SceneNode node, Matrix4x4 worldBefore, Matrix4x4 worldAfter) + { + var moved = new List<(SceneNode, Matrix4x4, Matrix4x4)>(); + var applied = new List(); + if (node.Source is CollisionInstanceAdapter) + { + // A placement changed on its own: nothing rides with it, but its object's record follows. + applied.AddRange(Relink(_host.LinksOf([node]))); + return (moved, applied); + } + if (node.Source is not IFrameNode || !Matrix4x4.Invert(worldBefore, out Matrix4x4 back)) return (moved, applied); + + Matrix4x4 delta = back * worldAfter; + List links = _host.LinkedSlots([node], new Dictionary { [node] = worldBefore }) + .TryGetValue(node, out List? mine) ? mine : []; + var hulls = new List<(SceneNode Node, Matrix4x4 Before)>(); + foreach (D3DImageHost.LinkedPlacement link in links) + { + if (link.Hull.Source is not IFrameNode h) continue; + hulls.Add((link.Hull, h.LocalTransform)); + h.LocalTransform = TransformOps.WorldDeltaToLocal(h.WorldTransform, h.ParentWorldTransform, delta); + SyncNodeMeshes(link.Hull); + } + if (hulls.Count == 0) return (moved, applied); + + // A placement cannot store a scale: a resize becomes a rescaled hull, as at the end of a drag. + applied.AddRange(_host.CollisionEditing.MintPreviewedScales(hulls.Select(x => x.Node).ToList())); + applied.AddRange(Relink(links)); + foreach ((SceneNode hull, Matrix4x4 hullBefore) in hulls) + { + moved.Add((hull, hullBefore, ((IFrameNode)hull.Source!).LocalTransform)); + } + return (moved, applied); + } + // True if any frame-graph ancestor of fn is itself selected (its cascade will move fn). private static bool HasSelectedAncestor(IFrameNode fn, HashSet selected) { @@ -170,7 +223,7 @@ _baselineNode is { } node public void GizmoCancelDrag() { _dragGroup.Clear(); - _dragLinks = new(); + _dragLinks = []; } /// Ends the drag: pushes the whole group move as ONE undoable edit. @@ -186,8 +239,8 @@ public void GizmoEndDrag() List mints = [.. _host.CollisionEditing.MintPreviewedScales(nodes)]; // After the mints in the composite, so before them on undo: the record goes back to the old hashes // while the placements still carry the new ones, and then the placements follow. - if (mints.Count > 0) mints.AddRange(RelinkAfterMint(_dragLinks)); - _dragLinks = new(); + mints.AddRange(Relink(_dragLinks)); + _dragLinks = []; var items = new List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)>(_dragGroup.Count); foreach ((SceneNode node, _, Matrix4x4 beforeLocal) in _dragGroup) @@ -203,43 +256,19 @@ public void RecordTransform(SceneNode node, Matrix4x4 before, Matrix4x4 after) { if (before == after) return; var items = new List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> { (node, before, after) }; - List mints = []; + List applied = []; // A number typed into the Transform panel comes through here, not through a drag — and the collision // an imported object was given has to follow it all the same. It used to stay where the object had // stood; past a few metres it was then no longer found as the object's, and a later delete left it in - // the file as an obstacle nobody can see. The hulls are looked for from where the object WAS, and - // given the same world-space change, the way a drag gives it to its group. - if (node.Source is IFrameNode moved and not CollisionInstanceAdapter) + // the file as an obstacle nobody can see. + if (node.Source is IFrameNode moved) { Matrix4x4 parent = moved.ParentWorldTransform; - Matrix4x4 worldBefore = before * parent; - if (Matrix4x4.Invert(worldBefore, out Matrix4x4 back)) - { - Matrix4x4 delta = back * (after * parent); - var hulls = new List<(SceneNode Node, Matrix4x4 Before)>(); - Dictionary> links = - _host.LinkedSlots([node], new Dictionary { [node] = worldBefore }); - foreach ((_, SceneNode hull) in links.TryGetValue(node, out List<(int Slot, SceneNode Hull)>? mine) ? mine : []) - { - if (hull.Source is not IFrameNode h) continue; - hulls.Add((hull, h.LocalTransform)); - h.LocalTransform = TransformOps.WorldDeltaToLocal(h.WorldTransform, h.ParentWorldTransform, delta); - SyncNodeMeshes(hull); - } - if (hulls.Count > 0) - { - // A placement cannot store a scale: a resize becomes a rescaled hull, as at the end of a drag. - mints = [.. _host.CollisionEditing.MintPreviewedScales(hulls.Select(x => x.Node).ToList())]; - if (mints.Count > 0) mints.AddRange(RelinkAfterMint(links)); - foreach ((SceneNode hull, Matrix4x4 hullBefore) in hulls) - { - items.Add((hull, hullBefore, ((IFrameNode)hull.Source!).LocalTransform)); - } - } - } + (List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> carried, applied) = CarryLinked(node, before * parent, after * parent); + items.AddRange(carried); } - RecordGroupTransform(items, mints); + RecordGroupTransform(items, applied); } // Records a group's local-transform changes as ONE undoable edit (keeping only the objects that moved), diff --git a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs index 3da39e4..5819c18 100644 --- a/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs +++ b/src/Illusion/Views/ScenePanel/Tabs/PropsTabView.xaml.cs @@ -159,17 +159,60 @@ private void Tiles_ScrollChanged(object sender, ScrollChangedEventArgs e) { _firstInView = Math.Clamp((int)(e.VerticalOffset / e.ExtentHeight * _paged.Count), 0, _paged.Count - 1); } - if (_paged.Count >= _filtered.Count || e.VerticalOffset + (2 * e.ViewportHeight) < e.ExtentHeight) return; + PageIfNearEnd(e.OriginalSource as ScrollViewer ?? FindScroller()); + } + + private void PageIfNearEnd(ScrollViewer? scroller) + { + if (scroller == null || _paged.Count >= _filtered.Count + || scroller.VerticalOffset + (2 * scroller.ViewportHeight) < scroller.ExtentHeight) + { + return; + } // A jump to the very end (the End key) keeps the view pinned there while the list grows under it, and // would walk through every page in one go — the two seconds this paging exists to avoid. One page per - // gesture, unless what is shown does not even fill the pane. - bool fills = e.ExtentHeight > e.ViewportHeight; - if (fills && (DateTime.UtcNow - _lastPage).TotalMilliseconds < 200) return; + // gesture, unless what is shown does not even fill the pane. A page held back is looked at again a + // moment later: at the very end no further scroll event comes to ask for it. + bool fills = scroller.ExtentHeight > scroller.ViewportHeight; + double since = (DateTime.UtcNow - _lastPage).TotalMilliseconds; + if (fills && since < 200) + { + if (_pageLater == null) + { + _pageLater = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(220) }; + _pageLater.Tick += (_, _) => + { + _pageLater!.Stop(); + PageIfNearEnd(FindScroller()); + }; + } + _pageLater.Stop(); + _pageLater.Start(); + return; + } _lastPage = DateTime.UtcNow; ShowAnotherPage(); _pictures.Start(); } + private DispatcherTimer? _pageLater; + + private ScrollViewer? FindScroller() + { + static ScrollViewer? Under(DependencyObject root) + { + int n = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < n; i++) + { + DependencyObject child = System.Windows.Media.VisualTreeHelper.GetChild(root, i); + if (child is ScrollViewer hit) return hit; + if (Under(child) is { } deeper) return deeper; + } + return null; + } + return Under(Tiles); + } + private DateTime _lastPage; // One picture per tick: a kept one if any tile shown still lacks it, otherwise one drawn — taking the From c236fc54aa38d0757889ce6f421af42ee15b3216 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 00:51:32 +0300 Subject: [PATCH 061/102] fix(cars): what a failed pack leaves, and a second look at the checks added here Read again before it went up. A pack that failed at its last step left files nobody knew of. The new archive is built beside the old one and a backup taken before the swap; with the game holding the archive the swap fails, and the .tmp and the backup - a 'version' of an archive that was never replaced - stayed. A clone taken back after that said the game's files were as they were. PackSds removes both when it fails. The clone's journal also notes the manifests of the tables and text working copies, which a pack may rewrite. The range check on tuning integers refused the upper half of an unsigned 64-bit field, which this API carries as a negative number. The 64-bit kinds take any value. The M2O export's check of an existing vehicles.json looked at the top level only: a list holding something that is not a car passed, the archives were copied, and reading the list then threw. Every item has to be an object, before anything is copied. The layer guard of view_set, merged in from the editor-tools branch, reads the map window's toggles here, where the session has two windows. And a census, since re-keying failures became refusals: --probe-car-census reads every car archive vehicles.tbl lists - 88 of 88 here, summer and winter, are keyed by their model name, so no stock car is refused. --probe-car-clone-rollback 14/14 (3 new: tables.sds held open as the running game holds it - refused and taken back, no .tmp, no backup, the tables and both car archives as they were). --probe-car-m2o 25/25, --probe-car-clone 77/77, --probe-tuning 68/68, --probe-editor-tools-live 12/12. --- src/Illusion.Assets/Cars/CarCloner.cs | 9 +- src/Illusion.Assets/Cars/CarM2oExport.cs | 3 +- .../EntityData/TuningEditing.cs | 3 +- src/Illusion.Assets/Sds/SdsWriter.cs | 46 ++++++--- src/Illusion/Diagnostics/ProbeRunner.cs | 4 + .../Diagnostics/Probes/CarCloneProbes.cs | 95 +++++++++++++++++++ .../Diagnostics/Probes/CarM2oExportProbes.cs | 8 ++ src/Illusion/Mcp/AppEditorSession.cs | 4 +- 8 files changed, 154 insertions(+), 18 deletions(-) diff --git a/src/Illusion.Assets/Cars/CarCloner.cs b/src/Illusion.Assets/Cars/CarCloner.cs index 1115e91..c484786 100644 --- a/src/Illusion.Assets/Cars/CarCloner.cs +++ b/src/Illusion.Assets/Cars/CarCloner.cs @@ -296,7 +296,14 @@ private static string TakenBack(string what, Exception ex, List problems // Everything refused above was refused before a byte was written. From here on files change, and // each is noted first. journal.Remember(vehiclesPath); - foreach (string text in folders.Text) journal.Remember(Path.Combine(text, "tables", "TextDatabase.dat")); + // The manifests too: a pack drops entries whose file is missing and rewrites the manifest, and a + // clone taken back should not leave that behind either. + journal.Remember(Path.Combine(folders.Tables, "SDSContent.xml")); + foreach (string text in folders.Text) + { + journal.Remember(Path.Combine(text, "tables", "TextDatabase.dat")); + journal.Remember(Path.Combine(text, "SDSContent.xml")); + } string ingameTables = Path.Combine(folders.Ingame, "tables"); if (Directory.Exists(ingameTables)) { diff --git a/src/Illusion.Assets/Cars/CarM2oExport.cs b/src/Illusion.Assets/Cars/CarM2oExport.cs index 0eda749..677cdd6 100644 --- a/src/Illusion.Assets/Cars/CarM2oExport.cs +++ b/src/Illusion.Assets/Cars/CarM2oExport.cs @@ -137,7 +137,8 @@ public static string DefaultResource(string car) => // file cut short — used to be taken for "no list yet" and written over with this one car: every // earlier registration, or the package's own settings, gone, after the archives had already been // replaced. It is a refusal, and the folder is left as it was. - if (Unreadable(vehiclesFile, node => node?["vehicles"] is JsonArray, "a list of vehicles") is { } badList) + if (Unreadable(vehiclesFile, node => node?["vehicles"] is JsonArray listed && listed.All(v => v is JsonObject), + "a list of vehicles") is { } badList) { refusal = badList; return null; diff --git a/src/Illusion.Assets/EntityData/TuningEditing.cs b/src/Illusion.Assets/EntityData/TuningEditing.cs index ffbf490..df68e5e 100644 --- a/src/Illusion.Assets/EntityData/TuningEditing.cs +++ b/src/Illusion.Assets/EntityData/TuningEditing.cs @@ -110,7 +110,8 @@ public static bool Restore(Change change, TuningValue value) ActorPropertyKind.UInt16 => (ushort.MinValue, ushort.MaxValue), ActorPropertyKind.Int32 => (int.MinValue, int.MaxValue), ActorPropertyKind.UInt32 => (uint.MinValue, uint.MaxValue), - ActorPropertyKind.UInt64 => (0, long.MaxValue), + // The 64-bit kinds take any value this API can carry: the upper half of an unsigned one arrives + // as a negative number, and a range here would refuse it. _ => null, }; return kind switch diff --git a/src/Illusion.Assets/Sds/SdsWriter.cs b/src/Illusion.Assets/Sds/SdsWriter.cs index 787c4d5..e76eac5 100644 --- a/src/Illusion.Assets/Sds/SdsWriter.cs +++ b/src/Illusion.Assets/Sds/SdsWriter.cs @@ -377,21 +377,41 @@ public static PackResult PackSds(FileInfo sds, bool createBackup, DateTime when) // caller before the temp is ever moved over the live archive. var tmp = new FileInfo(sds.FullName + ".tmp"); if (tmp.Exists) tmp.Delete(); - SdsArchive archive = SdsArchive.Pack(extracted, GameProfile.MafiaII, MemoryFor(sds, extracted)); - using (FileStream output = File.Create(tmp.FullName)) + string? backup = null; + try { - archive.Save(output, new SdsWriteOptions()); + SdsArchive archive = SdsArchive.Pack(extracted, GameProfile.MafiaII, MemoryFor(sds, extracted)); + using (FileStream output = File.Create(tmp.FullName)) + { + archive.Save(output, new SdsWriteOptions()); + } + tmp.Refresh(); + if (!tmp.Exists || tmp.Length == 0) + throw new IOException($"Packing {sds.Name} produced no output."); + + // Preserve the archive's current contents (the packer makes no backup of its own — bBackupEnabled is off), + // then swap the freshly-built archive into place. File.Move on the same volume is atomic, so the game .sds + // is never left half-written. The backup is taken AFTER the temp built successfully, so a failed build + // never spawns a spurious version. + backup = createBackup ? BackupArchive(sds, when) : null; + File.Move(tmp.FullName, sds.FullName, overwrite: true); + } + catch + { + // The archive is as it was — which is the point — but the temp beside it and a backup taken of an + // archive that was then NOT replaced are this call's own, and nobody else knows of them: with the + // game holding the archive, every failed build left a "version" in backups\ and a .tmp in the folder. + try + { + if (File.Exists(tmp.FullName)) File.Delete(tmp.FullName); + if (backup != null && File.Exists(backup)) File.Delete(backup); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // then they stay; the failure being reported is the pack's + } + throw; } - tmp.Refresh(); - if (!tmp.Exists || tmp.Length == 0) - throw new IOException($"Packing {sds.Name} produced no output."); - - // Preserve the archive's current contents (the packer makes no backup of its own — bBackupEnabled is off), - // then swap the freshly-built archive into place. File.Move on the same volume is atomic, so the game .sds - // is never left half-written. The backup is taken AFTER the temp built successfully, so a failed build - // never spawns a spurious version. - string? backup = createBackup ? BackupArchive(sds, when) : null; - File.Move(tmp.FullName, sds.FullName, overwrite: true); return new PackResult(sds.FullName, backup) { Dropped = dropped }; } } diff --git a/src/Illusion/Diagnostics/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 3895e7f..d89de70 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -181,6 +181,10 @@ public static bool TryRun(string[] args) case "--probe-car-clone-rollback": CarCloneProbes.RunCarCloneRollbackProbe(); return true; + // Which of the install's cars a clone or a substitution will take. Output: %TEMP%\illusion_car_census.txt + case "--probe-car-census": + CarCloneProbes.RunCarKeyCensus(); + return true; case "--probe-car-clone": CarCloneProbes.RunCarCloneProbe(); return true; diff --git a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs index 8878083..4d3cbf9 100644 --- a/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarCloneProbes.cs @@ -25,6 +25,78 @@ internal static class CarCloneProbes private const string Name = "Shubert_38_Clone"; private const string Title = "Shubert 38 Clone"; + // Output: %TEMP%\illusion_car_census.txt + /// + /// Which cars of this install are keyed the way a clone or a substitution needs: a root frame of the model's + /// name, a prefab entry and entity data filed under it, geometry buffers. A car that is not is refused by + /// both (it used to be built with a note); this says which ones those are. Reads the game's archives into + /// a scratch folder — nothing of the install is written. + /// + internal static void RunCarKeyCensus() + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_car_census.txt"); + string scratch = Path.Combine(Path.GetTempPath(), "illusion_car_census"); + var sb = new StringBuilder(); + int keyed = 0, looked = 0; + var odd = new List(); + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + string sds = Path.Combine(MafiaEnvironment.PcFolder, "sds"); + GameTable vehicles = GameTable.Load(Path.Combine( + SdsMeshLoader.EnsureExtracted(new FileInfo(Path.Combine(sds, "tables", "tables.sds"))), "tables", "vehicles.tbl")); + for (int row = 0; row < vehicles.RowCount; row++) + { + string model = (string)vehicles.Cell(row, 2); + foreach (string suffix in new[] { "", "_z" }) + { + var archive = new FileInfo(Path.Combine(sds, "cars", model.ToLowerInvariant() + suffix + ".sds")); + if (!archive.Exists) continue; + looked++; + var problems = new List(); + try + { + if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); + SdsArchive.Open(archive.FullName).Extract(scratch); + ExtractedSds loaded = ExtractedSds.Load(scratch); + if (loaded.FrameResource?.FrameObjects.Values.OfType() + .Any(f => string.Equals(f.Name.String, model, StringComparison.OrdinalIgnoreCase)) != true) + { + problems.Add("no frame of that name"); + } + if (loaded.VertexBuffers.Buffers.Count + loaded.IndexBuffers.Buffers.Count == 0) problems.Add("no buffers"); + foreach (string path in loaded.Manifest.GetFiles("PREFAB")) + { + if (!PrefabFile.Load(path).Contains(Fnv64.Hash(model))) problems.Add("prefab not keyed by it"); + } + foreach (string path in loaded.Manifest.GetFiles("EntityDataStorage")) + { + if (EntityDataStorageFile.Load(path).Hash != Fnv64.Hash(model.ToLowerInvariant())) problems.Add("entity data not filed under it"); + } + } + catch (Exception ex) + { + problems.Add("unreadable: " + ex.Message); + } + if (problems.Count == 0) keyed++; + else odd.Add($"{archive.Name} ({model}): {string.Join(", ", problems)}"); + } + } + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + try { if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); } + catch (IOException) { /* a scratch folder left behind is not worth failing for */ } + sb.Insert(0, $"CAR KEY CENSUS: {keyed} of {looked} car archives are keyed by their model name; {odd.Count} are not\n\n"); + foreach (string line in odd) sb.AppendLine(" " + line); + File.WriteAllText(outFile, sb.ToString()); + } + } + // Output: %TEMP%\illusion_car_clone.txt /// /// The real against the install, made to fail — the one thing the scratch @@ -128,6 +200,29 @@ bool Untouched() => snapshot.All(f => File.Exists(f.Key) && File.ReadAllBytes(f. Check("the same clone can be tried again — it is not refused as already existing", again == null && why != null && !why.Contains("already", StringComparison.Ordinal), why ?? ""); Check("…and the second failure leaves nothing behind either", Untouched()); + + // ── A pack that fails at its LAST step: the new archive built, the backup taken, the swap refused ── + // tables.sds is held open, as the running game holds it. The pack's own temp file and the + // backup of an archive that was then not replaced are nobody's to know about but the pack's. + Directory.Delete(blocker); + leftovers.Add(Path.Combine(sds, "cars", Failing + "_z.sds")); + string tablesTemp = tablesSds.FullName + ".tmp"; + using (new FileStream(tablesSds.FullName, FileMode.Open, FileAccess.Read, FileShare.Read)) + { + CarCloneOutcome? held = CarCloner.Clone("shubert_38", Failing, true, "Probe car", out why); + Check("a clone whose tables archive cannot be swapped in is refused and taken back", + held == null && why != null && why.Contains("taken back", StringComparison.Ordinal) + && !why.Contains("NOT everything", StringComparison.Ordinal), why ?? "cloned"); + } + tablesSds.Refresh(); + ingameSds.Refresh(); + Check("…leaving no temp file beside the archive and no backup of an archive that was not replaced", + !File.Exists(tablesTemp) && !File.Exists(ingameSds.FullName + ".tmp") + && SdsWriter.ListBackups(tablesSds).Count + SdsWriter.ListBackups(ingameSds).Count == backupsBefore + && tablesSds.LastWriteTimeUtc == packedAt.Tables && ingameSds.LastWriteTimeUtc == packedAt.Ingame); + Check("…and the tables, the folders and both car archives as they were", + Untouched() && !File.Exists(summer.FullName) && !File.Exists(Path.Combine(sds, "cars", Failing + "_z.sds")) + && !Directory.Exists(MafiaEnvironment.ExtractedDir(summer))); } } catch (Exception ex) diff --git a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs index a583c23..01ccf1b 100644 --- a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs @@ -123,6 +123,14 @@ void Check(string name, bool ok, string detail = "") && File.ReadAllText(Path.Combine(broken, CarM2oExport.PackageFile)) == BadPackage && Directory.GetFileSystemEntries(broken).Length == 2, unreadPackage ?? "exported"); + const string OddList = "{ \"vehicles\": [ \"not a car\" ] }"; + File.WriteAllText(Path.Combine(broken, CarM2oExport.VehiclesFile), OddList); + File.WriteAllText(Path.Combine(broken, CarM2oExport.PackageFile), "{ \"name\": \"mine\" }"); + Check("refuses a vehicles.json whose list holds something that is not a car, before copying anything", + CarM2oExport.ExportFrom(sources, broken, "car-test", out string? oddList) == null && oddList != null + && File.ReadAllText(Path.Combine(broken, CarM2oExport.VehiclesFile)) == OddList + && Directory.GetFileSystemEntries(broken).Length == 2, oddList ?? "exported"); + CarM2oExportResult? result = CarM2oExport.ExportFrom(sources, output, CarM2oExport.DefaultResource(Name), out refused); Check("exports the clone", result != null, refused ?? ""); if (result == null) return; diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index cad64f5..a05bee7 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -594,8 +594,8 @@ public CameraInfo Camera() // Switching a layer OFF unloads what it shows, and with it the unsaved edits made there and their // undo entries — a crash copy moved and never saved is simply gone. The same loss editor_open_area // refuses, by the same rule: not while edits are unsaved, unless the caller says they may go. - bool unloads = (crash == false && window.CrashToggle.IsChecked == true) - || (collision == false && window.CollisionToggle.IsChecked == true); + bool unloads = Window is { } map && ((crash == false && map.CrashToggle.IsChecked == true) + || (collision == false && map.CollisionToggle.IsChecked == true)); if (unloads && host.HasUnsavedEdits && !discardUnsavedEdits) { return "switching that layer off unloads it, and unsaved edits made in it are dropped together with " From 902583da81acddffd9f4ec98bd390cd5efcfb5a2 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:06:44 +0300 Subject: [PATCH 062/102] fix(bridge): the pre-pass refuses a material it cannot make; no empty undo entry Two things 4034b0f left. The check of every slot before any is written did not include a material that would have to be made and arrives with no picture to make it from - new, or one Blender remembers and the library no longer has: found at its own slot, after an earlier slot's texture had been replaced. Both are refused in the pre-pass now, in the words they had. And a push whose only texture changes were introductions made an undo entry with nothing in it - a Ctrl+Z that did nothing and cost the redo branch. --probe-bridge-material 62/62 (1 new: a repaint first, a new material with no image second - refused, the first left as it was). --- .../Bridge/AuthoredMaterialResolver.cs | 25 +++++++++++++++++++ .../Bridge/BridgeSessionController.cs | 5 +++- .../Probes/BridgeMaterialProbes.cs | 13 ++++++++++ src/Illusion/Viewport/TextureFilesEdit.cs | 4 +++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs b/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs index 68e9c89..de472f4 100644 --- a/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs +++ b/src/Illusion.Assets/Bridge/AuthoredMaterialResolver.cs @@ -96,6 +96,31 @@ public bool TryResolve(MeshObjectPayload payload, ISceneDocument document, out s { if (image != null && Pixels(image, out skipReason) == null) return false; } + + // A material that would have to be MADE and arrives without a picture to make it from — new, or + // one Blender remembers and the library no longer has. Both are told apart further down, where + // each gets its own words; here it only matters that neither is found after an earlier slot of + // the same object has been written. (A slot earlier in this object that brings the picture under + // the same name makes the material first, and this one then binds to it.) + bool stamped = BridgeMeshApplier.TryParseMaterialHash(info.Hash, out ulong stampedHash) && stampedHash != 0; + bool known = stamped ? MafiaMaterials.KnowsMaterial(stampedHash) || !info.Authored : false; + if (!known && name.Length > 0 && info.DiffuseImage == null && MafiaMaterials.FindHashByName(name) == null + && !payload.Materials.TakeWhile(other => !ReferenceEquals(other, info)).Any(other => other.DiffuseImage != null + && string.Equals(other.Name?.Trim(), name, StringComparison.Ordinal))) + { + if (stamped) + { + if (_acknowledged.Add(name)) Resolved.Add(new PushMaterial { Name = name, Hash = "" }); + skipReason = $"material '{name}' is no longer in the game's library (undone, or never saved) — " + + "push again and it will be created anew"; + } + else + { + skipReason = $"material '{name}' is new and has no image on Base Color — " + + "plug an Image Texture into it (plain colours are not supported yet)"; + } + return false; + } } foreach (MeshMaterialInfo info in payload.Materials) diff --git a/src/Illusion/Bridge/BridgeSessionController.cs b/src/Illusion/Bridge/BridgeSessionController.cs index 3c948a2..665b595 100644 --- a/src/Illusion/Bridge/BridgeSessionController.cs +++ b/src/Illusion/Bridge/BridgeSessionController.cs @@ -945,7 +945,10 @@ private void ApplyPush(PushMessage push) // The catalog's own history cannot do it — a repaint under the same name changes no binding // and so leaves no entry there at all. if (authored.TextureChanges.Count > 0) - collisionEdits.Add(new TextureFilesEdit(_host, authored.TextureChanges, authored.TouchedArchives.Values)); + { + var textures = new TextureFilesEdit(_host, authored.TextureChanges, authored.TouchedArchives.Values); + if (!textures.IsEmpty) collisionEdits.Add(textures); + } foreach (ReshapedHull hull in reshapes) { if (!_host.Tree.IsInScene(hull.Node) || hull.Node.Parent is not { } layer diff --git a/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs b/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs index a20f579..caa7f05 100644 --- a/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs +++ b/src/Illusion/Diagnostics/Probes/BridgeMaterialProbes.cs @@ -379,6 +379,19 @@ static string Shape(Formats.Materials.Versions.Material_v57 m) => !both && File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) && twoSlots.TextureChanges.Count == 0 && MafiaMaterials.FindHashByName(bad.Name) == null, reason ?? "resolved"); + // The same with a second slot that is new and has no picture at all. + var pictureless = new ExchangeContainer(); + MeshMaterialInfo repaintFirst = NewSlot(pictureless, Gradient(w, h, 152), w, h); + repaintFirst.Hash = slot.Hash; + var noPicture = new MeshMaterialInfo { Hash = "", Name = ProbeMaterial + "_no_picture", Authored = true }; + var bareSecond = new AuthoredMaterialResolver(pictureless, catalogHost); + bool bareTaken = bareSecond.TryResolve( + new MeshObjectPayload { Id = "new:probe", Name = "probe", Materials = { repaintFirst, noPicture } }, document, out reason); + Check("…and so is one whose second material is new and has no image, with the first left as it was", + !bareTaken && reason != null && reason.Contains("no image", StringComparison.Ordinal) + && File.ReadAllBytes(Path.Combine(extracted, held)).AsSpan().SequenceEqual(heldBytes) + && bareSecond.TextureChanges.Count == 0, reason ?? "resolved"); + // A write that fails — the texture held open by something — is not remembered as done: the // next object of the same push sharing the image is refused too, not handed a file that was // never written. diff --git a/src/Illusion/Viewport/TextureFilesEdit.cs b/src/Illusion/Viewport/TextureFilesEdit.cs index 1effb7a..6185ed9 100644 --- a/src/Illusion/Viewport/TextureFilesEdit.cs +++ b/src/Illusion/Viewport/TextureFilesEdit.cs @@ -39,6 +39,10 @@ public TextureFilesEdit(D3DImageHost host, IReadOnlyListWhether there is anything here to take back — a push that only introduced textures has + /// nothing, and an entry made of it would be a Ctrl+Z that does nothing. + public bool IsEmpty => _changes.Count == 0; + public void Undo() { if (!_onDisk) return; From e4a17f85377a310c65dc845a43a7d1f844358c3d Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:08:17 +0300 Subject: [PATCH 063/102] fix(mcp): the layer guard is about the crash layer only 01973ba guarded switching the collision layer off as well, on the belief that it unloads the layer. It only hides it: the placements, their edits and their undo entries stay, so the guard refused for nothing and its discardUnsavedEdits discarded nothing. Crash only, which does unload. And object_move refuses a position plus offset that overflows, though each number alone is finite. --probe-editor-tools-live 14/14 (collision on and off with an edit unsaved; the overflowing move). --- src/Illusion.Mcp/Tools/EditorTools.cs | 2 +- .../Diagnostics/Probes/EditorToolLiveProbes.cs | 5 +++++ src/Illusion/Mcp/AppEditorSession.cs | 12 +++++++----- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index a5962b6..54e294c 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -381,7 +381,7 @@ public static async Task SetView( [Description("Show the city_crash prop layer.")] bool? crash = null, [Description("Show district load zones.")] bool? zones = null, [Description("Show AI navigation overlays.")] bool? navigation = null, - [Description("Switch a layer off even though the scene has unsaved edits — those made in that layer are lost with their undo entries. Default false.")] bool discardUnsavedEdits = false) + [Description("Switch the crash layer off even though the scene has unsaved edits — those made in it are lost with their undo entries. Default false.")] bool discardUnsavedEdits = false) { try { diff --git a/src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs b/src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs index 1d6df63..425ae86 100644 --- a/src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs +++ b/src/Illusion/Diagnostics/Probes/EditorToolLiveProbes.cs @@ -75,6 +75,11 @@ void Check(string name, bool ok, string detail = "") guarded != null && session.Status().UnsavedEdits && window.CrashToggle.IsChecked == true, guarded ?? "switched off"); Check("switching another layer ON is not what the guard is about", session.SetView(null, null, null, zones: true, null) == null); + Check("nor is the collision layer, which is only hidden: it switches on and off with the edit unsaved", + session.SetView(null, collision: true, null, null, null) == null + && session.SetView(null, collision: false, null, null, null) == null && session.Status().UnsavedEdits); + Check("a move further than a position can be is refused, though each number is finite", + mesh != null && session.Move(mesh.Name, [3e38f, 0f, 0f], [3e38f, 0f, 0f]) != null); Check("and with the edits given up it is switched off", session.SetView(null, null, crash: false, null, null, discardUnsavedEdits: true) == null && window.CrashToggle.IsChecked != true); diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 00e8062..c315fac 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -397,14 +397,15 @@ public CameraInfo Camera() return $"unknown shading mode '{renderMode}' — one of {string.Join(", ", Enum.GetNames())}"; if ((collision != null || crash != null) && host.BridgeEditedCount > 0) return "the collision and crash layers reload part of the scene — blender_end first"; - // Switching a layer OFF unloads what it shows, and with it the unsaved edits made there and their - // undo entries — a crash copy moved and never saved is simply gone. The same loss editor_open_area + // Switching the CRASH layer off unloads it, and with it the unsaved edits made there and their undo + // entries — a crash copy moved and never saved is simply gone. The same loss editor_open_area // refuses, by the same rule: not while edits are unsaved, unless the caller says they may go. - bool unloads = (crash == false && window.CrashToggle.IsChecked == true) - || (collision == false && window.CollisionToggle.IsChecked == true); + // (The collision layer is only hidden when it is switched off: its placements, their edits and + // their undo entries stay, so there is nothing to guard there.) + bool unloads = crash == false && window.CrashToggle.IsChecked == true; if (unloads && host.HasUnsavedEdits && !discardUnsavedEdits) { - return "switching that layer off unloads it, and unsaved edits made in it are dropped together with " + return "switching the crash layer off unloads it, and unsaved edits made in it are dropped together with " + "their undo entries — editor_save first, or pass discardUnsavedEdits=true to give them up"; } @@ -460,6 +461,7 @@ public CameraInfo Camera() Vector3 delta = Vector3.Zero; if (position != null) delta = new Vector3(position[0], position[1], position[2]) - frame.WorldTransform.Translation; if (offset != null) delta += new Vector3(offset[0], offset[1], offset[2]); + if (!float.IsFinite(delta.X) || !float.IsFinite(delta.Y) || !float.IsFinite(delta.Z)) return "that is further than a position can be"; if (delta == Vector3.Zero) return null; // The gizmo's own path, start to finish: it is what knows how each kind of object moves (a frame, From b2f3b79f3a497797b0a721966611bd72572c3371 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:09:53 +0300 Subject: [PATCH 064/102] fix(mirror): wearers that disagree, names that differ in case, an entry that cannot be copied Three things 7750e77 left. Wearers of one material block that give different winter materials for a slot: the first in file order won and both were counted as settled; the slot keeps summer's and the objects are put down as in doubt. A texture entry compared with summer's by exact spelling of the file name: a material that spells it otherwise than the manifest made every mirror rewrite the entry and report a refresh. And the old entry was removed before knowing summer's could be copied in its place. --probe-season-mirror 28/28. --- src/Illusion.Assets/Sds/SeasonMirror.cs | 32 ++++++++++++++++--------- 1 file changed, 21 insertions(+), 11 deletions(-) diff --git a/src/Illusion.Assets/Sds/SeasonMirror.cs b/src/Illusion.Assets/Sds/SeasonMirror.cs index 68a163b..e250a23 100644 --- a/src/Illusion.Assets/Sds/SeasonMirror.cs +++ b/src/Illusion.Assets/Sds/SeasonMirror.cs @@ -414,21 +414,23 @@ private static List PayloadsOf(SdsArchive archive, string type) { MaterialStruct material = block.Materials[lod][slot]; ulong summerHash = material.MaterialHash; - bool seasonal = false, doubt = false; + bool doubt = false; + var answers = new HashSet(); foreach ((ulong name, int occurrence) in wearers[block]) { SeasonPair.Answer answer = pair.Resolve(name, occurrence, namesakes[name], lod, slot, summerHash, out ulong winter); - if (answer == SeasonPair.Answer.Seasonal) - { - material.MaterialHash = winter; - seasonal = true; - break; - } + if (answer == SeasonPair.Answer.Seasonal) answers.Add(winter); doubt |= answer == SeasonPair.Answer.Ambiguous; } - if (seasonal) continue; - if (doubt) unsure = true; + if (answers.Count == 1) + { + material.MaterialHash = answers.First(); + continue; + } + // Wearers that disagree about what this slot is in winter — the block cannot be both. It + // keeps summer's material and is put down as in doubt, like any other that cannot be told. + if (answers.Count > 1 || doubt) unsure = true; else reassigned++; } } @@ -544,9 +546,17 @@ bool Bring(string name) // the old one is an entry promising a companion that is no longer there — a chain the game // streams and does not find — or one hiding a companion that now is. IReadOnlyList<(string Name, string Value)>? theirs = from.Manifest.EntryFields(name), ours = manifest.EntryFields(name); - bool sameEntry = theirs != null && ours != null && theirs.SequenceEqual(ours); + // File names are compared the way the manifests are searched — without regard to case: a material + // may spell a texture otherwise than summer's manifest does, and a difference in spelling alone + // would have every mirror rewrite the entry and report the texture as refreshed. + bool sameEntry = theirs != null && ours != null && theirs.Count == ours.Count + && theirs.Zip(ours).All(f => f.First.Name == f.Second.Name && (f.First.Name == "File" + ? string.Equals(f.First.Value, f.Second.Value, StringComparison.OrdinalIgnoreCase) + : f.First.Value == f.Second.Value)); if (sameBytes && sameEntry) return false; - if (!sameEntry && theirs != null) + // Replaced only when summer's entry is one that can be copied: the old entry is taken out first, + // and an entry that then could not be written would leave the file unlisted. + if (!sameEntry && theirs is { Count: >= 3 } && theirs[^1].Name == "Version" && int.TryParse(theirs[^1].Value, out _)) { manifest.RemoveEntry(name); return CopyEntry(from.Manifest, manifest, summerDir, winterDir, name); From c5ec22a38747655308848d230a15146029b586b7 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:13:50 +0300 Subject: [PATCH 065/102] fix(import): the collision links once more - typed and pushed moves of a hull, two objects of one name, parking on record first What a second independent reading of d880b76 found. A hull moved on its own by a typed number or by a push from Blender still lost its tie past five metres: it was looked for after it had moved, and only the drag asked beforehand. It is recognised by where it stood (LinksOf takes the place it had), and the record follows. A push from Blender carried a hull that Blender itself places. With the hull in the session, the editor moved it with its object and the next push moved it back - or, with both moved in one push, it went twice. A push carries only placements the session does not hold; what rides with each moved object is worked out once for the batch, before anything is applied, and the records are brought in line once at the end, with everything where the push leaves it. Two objects of one name. A record's owner was the first frame of that name in the file, so renaming an earlier object onto a later one's name handed it the later one's collision. Among namesakes the owner is the one the record fits - whose matrix puts the recorded places where those hulls stand. And a rename moves a record only for the one object bearing the name: renaming the newcomer away again leaves the other's record be. A record from before places were kept gains its places the first time anything is done to one of its placements, instead of never. Parking wrote its index at the end of the sweep, after the originals were gone, and the sweep now swallows failures: an index that could not be written left textures removed and nowhere on record. The index is written before anything is taken out of the working copy; a texture whose removal only half succeeds stays carried and is tried again. The prop reader no longer re-holds an archive the tab let go while it was being read. --probe-object-import-live 48/48 (4 new: a hull typed sixteen metres away on its own stays the object's, with its undo; an earlier object renamed onto a later one's name does not take its collision, and renamed back each has its own). --probe-object-transplant 55/55, --probe-prop-catalog 12/12. Not checked: a push with a hull in the Blender session (no Blender in the run - the batch is driven directly, with no session); an imported object under a moved parent; the parking failure paths. --- .../Properties/FramePropertyCatalog.cs | 6 ++ src/Illusion.Assets/Sds/ArchiveCarry.cs | 40 +++++---- .../Probes/ObjectImportLiveProbes.cs | 29 +++++++ src/Illusion/Viewport/D3DImageHost.cs | 59 ++++++++++---- .../Viewport/GeometryEditController.cs | 32 ++++++-- .../Viewport/PropThumbnailRenderer.cs | 8 +- .../Viewport/TransformEditController.cs | 81 ++++++++++--------- 7 files changed, 180 insertions(+), 75 deletions(-) diff --git a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs index ae72390..687aaa0 100644 --- a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs +++ b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs @@ -69,7 +69,13 @@ private static void AddBase(FrameObjectBase o, FrameNodeAdapter node, GroupColle private static void Rename(FrameObjectBase o, FrameNodeAdapter node, string name) { string old = o.Name.String; + // Only for the one object that bears the name. With two of a name — an earlier rename onto a name + // that was taken — the record under it belongs to one of them and cannot be told to follow the + // other: renaming the newcomer away again must leave it where it is. + bool sole = !node.Document.Frame.FrameObjects.Values.OfType() + .Any(f => !ReferenceEquals(f, o) && string.Equals(f.Name.String, old, StringComparison.Ordinal)); o.Name.Set(name); + if (!sole) return; try { Sds.ImportLinks.Rename(MafiaEnvironment.ExtractedDir(node.Document.SourceArchive), old, name); diff --git a/src/Illusion.Assets/Sds/ArchiveCarry.cs b/src/Illusion.Assets/Sds/ArchiveCarry.cs index 37eeb4b..b167926 100644 --- a/src/Illusion.Assets/Sds/ArchiveCarry.cs +++ b/src/Illusion.Assets/Sds/ArchiveCarry.cs @@ -361,16 +361,12 @@ private static void WriteParked(string dir, Dictionary> AtomicFile.WriteAllBytes(Path.Combine(folder, ParkedIndex), System.Text.Json.JsonSerializer.SerializeToUtf8Bytes(parked)); } - // Out of the manifest and into the parked folder, in two steps. First BOTH files are copied aside and - // written down; a failure there leaves the texture exactly as it was (it stays carried). Only then are - // the entries dropped and the originals removed — and whatever fails in that half, what was copied is - // still returned, so it is on record as parked: an entry already gone with its file not recorded was a - // texture the next sweep deleted for good. - private static List Park(SdsManifest manifest, string dir, string texture, List dropped) + // Parking, first half: both files copied aside, with the manifest entries they had. Nothing of the + // working copy is changed; a failure here leaves the texture exactly as it was. + private static List CopyAside(SdsManifest manifest, string dir, string texture) { var files = new List(); - string[] both = [texture, SdsImportTypes.MipNameFor(texture)]; - foreach (string file in both) + foreach (string file in new[] { texture, SdsImportTypes.MipNameFor(texture) }) { string path = Path.Combine(dir, file); IReadOnlyList<(string Name, string Value)>? fields = manifest.EntryFields(file); @@ -380,8 +376,16 @@ private static List Park(SdsManifest manifest, string dir, string te File.Copy(path, parkedPath, overwrite: true); files.Add(new ParkedFile { File = file, Fields = [.. fields.Select(f => new[] { f.Name, f.Value })] }); } + return files; + } - foreach (string file in both) + // Parking, second half — only once the copies are on record ON DISK: the entries dropped and the + // originals removed. False when either could not be; the texture then stays carried and is tried again + // at the next save, its copies already safe. + private static bool RemoveOriginals(SdsManifest manifest, string dir, string texture, List dropped) + { + bool whole = true; + foreach (string file in new[] { texture, SdsImportTypes.MipNameFor(texture) }) { try { @@ -390,11 +394,10 @@ private static List Park(SdsManifest manifest, string dir, string te } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { - // Still listed, or listed no more and its file still there: either way the copy is safe and - // on record, and the putting back sorts it out (a file still listed is simply left). + whole = false; } } - return files; + return whole; } // Back where it was, announced as it was. False when a file of it could not be put back — it stays @@ -545,13 +548,22 @@ private static IReadOnlyList Sweep(string extracted, Formats.Frames.Fram if (named.Contains(texture)) continue; try { - List files = Park(manifest, extracted, texture, dropped); + List files = CopyAside(manifest, extracted, texture); if (files.Count > 0) { + // What an earlier, interrupted attempt already set aside stays on record beside it. + if (parked.TryGetValue(texture, out List? earlier)) + { + files.AddRange(earlier.Where(e => !files.Any(f => string.Equals(f.File, e.File, StringComparison.OrdinalIgnoreCase)))); + } parked[texture] = files; ParkedHere.Add(ParkedKey(extracted, texture)); + // Written down before anything is taken out of the working copy: with the index + // written only at the end, a failure there left textures removed and nowhere on + // record, and the next sweep cleared the folder they were parked in. + WriteParked(extracted, parked); } - carried.Remove(texture); + if (RemoveOriginals(manifest, extracted, texture, dropped)) carried.Remove(texture); } catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { diff --git a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs index 319a58f..0f3e9ce 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs @@ -212,6 +212,22 @@ Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) host.LinkedCollisionNodes(node).Count == 1 && ImportLinks.HullsOf(dir, "probe_live") is [{ } asWas] && asWas.At is { } placeAsWas && Vector3.Distance(placeAsWas, Relative()) < 0.02f); + // ── the same, with the hull's place TYPED rather than dragged: it has moved by the time it is recorded ── + { + Matrix4x4 hullFrom = hull.LocalTransform, hullTo = hullFrom; + hullTo.Translation += new Vector3(0f, 16f, 0f); + hull.LocalTransform = hullTo; + host.RecordTransform(hulls[0], hullFrom, hullTo); + Check("a hull typed sixteen metres away on its own is still the object's", + host.LinkedCollisionNodes(node).Count == 1 + && ImportLinks.HullsOf(dir, "probe_live") is [{ } typed] && typed.At is { } typedAt + && Vector3.Distance(typedAt, Relative()) < 0.02f); + host.Undo(); + Check("…and its undo puts the hull and the record back", + ProbeAssert.Approx(hull.LocalTransform.Translation, hullFrom.Translation, 0.01f) + && host.LinkedCollisionNodes(node).Count == 1); + } + // ── a transform pushed from Blender takes the hull along, as one typed or dragged does ── { Vector3 hullWas = hull.WorldTransform.Translation; @@ -283,6 +299,19 @@ Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) host.Undo(); Check("…and after the undo both objects have their own records", ImportLinks.HullsOf(dir, "probe_live_renamed").SequenceEqual(mine) && ImportLinks.HullsOf(dir, "probe_live_b").Count == 1); + + // The other way round: the EARLIER object renamed onto the later one's name. It comes first in + // the file under that name now — and must not come by the later one's collision for that. + SceneNode? later = Nodes(host).FirstOrDefault(n => n.Source is FrameNodeAdapter f && f.Frame.Name.String == "probe_live_b"); + string? taken = session.SetProperty("probe_live_renamed", "Base.Name", "probe_live_b"); + Check("an earlier object renamed onto a later one's name does not take over its collision", + taken == null && later != null && host.LinkedCollisionNodes(later).Count == 1 + && host.LinkedCollisionNodes(node).Count == 0, + taken ?? $"the later object has {(later == null ? -1 : host.LinkedCollisionNodes(later).Count)}, the renamed one {host.LinkedCollisionNodes(node).Count}"); + host.Undo(); + Check("…and renamed back, each has its own again", + later != null && host.LinkedCollisionNodes(later).Count == 1 && host.LinkedCollisionNodes(node).Count == given + && ImportLinks.HullsOf(dir, "probe_live_renamed").SequenceEqual(mine)); host.Undo(); // the second import } else diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index 1068065..95de50f 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -360,7 +360,7 @@ internal Dictionary> LinkedSlots(IEnumerable mine = [.. all.Where(p => IsSelfOrUnder(p.Owner, a.Frame.Frame))]; @@ -374,7 +374,9 @@ internal Dictionary> LinkedSlots(IEnumerable - internal List LinksOf(IEnumerable placements) + /// Where a placement stood before the change being recorded, for one that has + /// already been moved: it is by where it WAS that it is recognised as its object's. + internal List LinksOf(IEnumerable placements, IReadOnlyDictionary? stoodAt = null) { var found = new List(); foreach (var layer in placements.Where(n => n.Source is CollisionInstanceAdapter && n.Parent != null).GroupBy(n => n.Parent!)) @@ -390,7 +392,7 @@ void Walk(SceneNode node) Walk(layer.Key.Parent ?? layer.Key); if (documentNode?.Source is not SceneDocumentAdapter scene) continue; var wanted = new HashSet(layer); - found.AddRange(Assignments(scene, documentNode, _ => null).Where(p => wanted.Contains(p.Hull))); + found.AddRange(Assignments(scene, documentNode, _ => null, stoodAt).Where(p => wanted.Contains(p.Hull))); } return found; } @@ -408,23 +410,13 @@ private static bool IsSelfOrUnder(Formats.Frames.ObjectTypes.FrameObjectBase fra // Every placement of an archive that some imported object's record claims, worked out once: each recorded // entry bids for the placements of its hull, nearest first, and a placement goes to the first bid only. private List Assignments(SceneDocumentAdapter scene, SceneNode documentNode, - Func lookFrom) + Func lookFrom, IReadOnlyDictionary? stoodAt) { var assigned = new List(); string dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); IReadOnlyDictionary> links = Assets.Sds.ImportLinks.All(dir); if (links.Count == 0 || FindCollisionLayer(documentNode) is not { } layer) return assigned; - // The object a recorded name belongs to: the first of that name, as the link was made for. - var owners = new Dictionary(StringComparer.Ordinal); - foreach (object value in scene.Frame.FrameObjects.Values) - { - if (value is Formats.Frames.ObjectTypes.FrameObjectBase f && f.Name.String is { Length: > 0 } n && links.ContainsKey(n)) - { - owners.TryAdd(n, f); - } - } - List placements = [.. layer.Children]; var byHull = new Dictionary>(); for (int i = 0; i < placements.Count; i++) @@ -433,6 +425,43 @@ private List Assignments(SceneDocumentAdapter scene, SceneNode if (!byHull.TryGetValue(ci.Instance.Hash, out List? of)) byHull[ci.Instance.Hash] = of = []; of.Add(i); } + Vector3 Stands(int i) => stoodAt != null && stoodAt.TryGetValue(placements[i], out Vector3 was) + ? was + : ((CollisionInstanceAdapter)placements[i].Source!).Instance.Position; + + // The object a recorded name belongs to. Usually there is one of that name. When a rename has left + // two, it is the one the record FITS — whose matrix puts the recorded places where placements of + // those hulls stand — and not simply the first in the file: by file order, renaming an earlier object + // onto a later one's name handed it the later one's collision. + var owners = new Dictionary(StringComparer.Ordinal); + foreach (var named in scene.Frame.FrameObjects.Values.OfType() + .Where(f => f.Name.String is { Length: > 0 } n && links.ContainsKey(n)).GroupBy(f => f.Name.String, StringComparer.Ordinal)) + { + List bearers = [.. named]; + if (bearers.Count == 1) + { + owners[named.Key] = bearers[0]; + continue; + } + float Misfit(Formats.Frames.ObjectTypes.FrameObjectBase bearer) + { + Matrix4x4 standing = lookFrom(bearer) ?? bearer.WorldTransform; + float total = 0f; + foreach (Assets.Sds.ImportLinks.Link link in links[named.Key]) + { + if (link.At is not { } place) continue; + Vector3 expected = Vector3.Transform(place, standing); + float nearest = 1e6f; + if (byHull.TryGetValue(link.Hull, out List? candidates)) + { + foreach (int i in candidates) nearest = MathF.Min(nearest, Vector3.Distance(expected, Stands(i))); + } + total += nearest; + } + return total; + } + owners[named.Key] = bearers.MinBy(Misfit)!; + } // A tie — a copy on top of its original — goes to the name that sorts first, so the answer does not // change from one call to the next. Two entries for the same hull take two placements. @@ -449,7 +478,7 @@ private List Assignments(SceneDocumentAdapter scene, SceneNode if (!byHull.TryGetValue(hulls[slot].Hull, out List? candidates)) continue; foreach (int i in candidates) { - Vector3 at = ((CollisionInstanceAdapter)placements[i].Source!).Instance.Position; + Vector3 at = Stands(i); float distance; if (hulls[slot].At is { } place) { diff --git a/src/Illusion/Viewport/GeometryEditController.cs b/src/Illusion/Viewport/GeometryEditController.cs index c084085..36e3b44 100644 --- a/src/Illusion/Viewport/GeometryEditController.cs +++ b/src/Illusion/Viewport/GeometryEditController.cs @@ -87,6 +87,15 @@ public List ApplyPushBatch( } } + // The collision of imported objects, for the transforms of this push. Worked out BEFORE any of them + // is applied — once for the whole batch — while everything still stands where the records say: + // which placements ride with each moved object, and whose each moved placement is. + var pushed = new HashSet(transforms.Select(t => t.Node)); + Dictionary> riding = + _host.LinkedSlots(transforms.Where(t => t.Node.Source is not CollisionInstanceAdapter).Select(t => t.Node)); + List touched = _host.LinksOf(transforms.Select(t => t.Node)); + var minted = new List(); + foreach (TransformItem item in transforms) { Matrix4x4? parent = item.Node.Source is IFrameNode placed ? placed.ParentWorldTransform : null; @@ -96,17 +105,30 @@ public List ApplyPushBatch( // An object moved in Blender takes the collision it was given along, as one moved in the editor // does: those placements are tied to it by nothing in the file, and left behind they are an - // obstacle where the object used to stand. - if (parent is not { } parentWorld) continue; - (List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> carried, List applied) = - _host.Editing.CarryLinked(item.Node, item.Before * parentWorld, item.After * parentWorld); + // obstacle where the object used to stand. NOT the placements the session holds itself, though: + // Blender says where those are, in this push and in every later one, and a hull carried here + // would be put back by the next push — or moved twice by this one. + if (parent is not { } parentWorld || !riding.TryGetValue(item.Node, out List? riders) + || !Matrix4x4.Invert(item.Before * parentWorld, out Matrix4x4 back)) + { + continue; + } + List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> carried = _host.Editing.MoveRiders( + riders, back * (item.After * parentWorld), + hull => pushed.Contains(hull) || _host.BridgeSession.IsEditedNode(hull), out List cooked); foreach ((SceneNode hull, Matrix4x4 hullBefore, Matrix4x4 hullAfter) in carried) { children.Add(new TransformSubEdit(this, new TransformItem(hull, hullBefore, hullAfter))); _host.Persistence.MarkFrameModified(hull); } - children.AddRange(applied); + minted.AddRange(cooked); + touched.AddRange(riders); } + // Last, with everything where the push leaves it: the records are brought in line once — a placement + // that rode keeps its place in its object's space, one the session moved (or left behind) gets the + // place it now has. + children.AddRange(minted); + children.AddRange(_host.Editing.Relink([.. touched.Distinct()])); foreach (CreationItem item in creations) { diff --git a/src/Illusion/Viewport/PropThumbnailRenderer.cs b/src/Illusion/Viewport/PropThumbnailRenderer.cs index 5370188..699e36f 100644 --- a/src/Illusion/Viewport/PropThumbnailRenderer.cs +++ b/src/Illusion/Viewport/PropThumbnailRenderer.cs @@ -42,6 +42,7 @@ internal sealed class PropThumbnailRenderer : IDisposable private bool _failed; private readonly object _read = new(); + private int _rested; // counts the times the held archive was let go private string? _archive; // the archive whose scene is held below private string? _archiveDir; private ExtractedSds? _scene; @@ -84,9 +85,11 @@ public Staged Stage(PropEntry entry) ExtractedSds? held; ActorPlacements? placements; string? dir; + int asOf; lock (_read) { (held, placements, dir) = _archive == entry.Archive ? (_scene, _placements, _archiveDir) : (null, null, null); + asOf = _rested; } if (held == null || dir == null) { @@ -99,7 +102,9 @@ public Staged Stage(PropEntry entry) placements = held.FrameResource is { } fr ? ActorPlacements.Load(held.Manifest, fr) : null; lock (_read) { - (_scene, _placements, _archiveDir, _archive) = (held, placements, dir, entry.Archive); + // Not kept when the tab let its archive go while this was being read: nobody is left to + // ask for the next prop of it. + if (asOf == _rested) (_scene, _placements, _archiveDir, _archive) = (held, placements, dir, entry.Archive); } } if (held?.FrameResource is not { } scene || placements == null) return new Staged { Entry = entry }; @@ -186,6 +191,7 @@ public void Rest() _scene = null; _placements = null; _archive = null; + _rested++; } } diff --git a/src/Illusion/Viewport/TransformEditController.cs b/src/Illusion/Viewport/TransformEditController.cs index de256db..12bac6e 100644 --- a/src/Illusion/Viewport/TransformEditController.cs +++ b/src/Illusion/Viewport/TransformEditController.cs @@ -114,11 +114,12 @@ internal List Relink(IReadOnlyList li if (link.Slot >= entries.Count || link.Hull.Source is not CollisionInstanceAdapter placed) continue; Assets.Sds.ImportLinks.Link now = D3DImageHost.LinkFor(link.Owner, placed.Instance); Assets.Sds.ImportLinks.Link was = entries[link.Slot]; - bool samePlace = was.At is not { } before || now.At is not { } after || Vector3.Distance(before, after) < 0.01f; - if (was.Hull == now.Hull && samePlace) continue; - // A record without a place keeps none unless the placement moved on its own — it is matched - // by nearness, and a place written now would be right only until the next change. - entries[link.Slot] = samePlace ? was with { Hull = now.Hull } : now; + // A record from before places were kept gains one here, the first time anything is done to + // the placement: found by nearness this once, it is found by its place from now on — and + // without it a hull of such a record dragged a little too far on its own was lost. + bool samePlace = was.At is { } before && now.At is { } after && Vector3.Distance(before, after) < 0.01f; + if (was.Hull == now.Hull && (samePlace || now.At == null)) continue; + entries[link.Slot] = now.At == null ? was with { Hull = now.Hull } : now; changed = true; } if (!changed) continue; @@ -130,46 +131,29 @@ internal List Relink(IReadOnlyList li } /// - /// Moves the collision an imported object was given — and what was given to anything under it — by the - /// change that took the object from to , for - /// the ways of moving an object that are not a drag: a number typed into the panel, a transform pushed - /// from Blender. The placements are looked for from where the object WAS. Returns what moved, for the - /// caller's undo entry, and the edits already applied alongside (re-cooked hulls, the records). + /// Moves placements that ride with an object by the change made to it, and + /// re-cooks the ones a resize reached (a placement cannot store a scale). Returns what moved, for the + /// caller's undo entry; are the re-cook edits, already applied. /// - internal (List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> Moved, List Applied) CarryLinked( - SceneNode node, Matrix4x4 worldBefore, Matrix4x4 worldAfter) + /// Placements that are not to be carried: ones the caller places itself. + internal List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> MoveRiders( + IEnumerable riders, Matrix4x4 delta, Func? skip, + out List minted) { - var moved = new List<(SceneNode, Matrix4x4, Matrix4x4)>(); - var applied = new List(); - if (node.Source is CollisionInstanceAdapter) - { - // A placement changed on its own: nothing rides with it, but its object's record follows. - applied.AddRange(Relink(_host.LinksOf([node]))); - return (moved, applied); - } - if (node.Source is not IFrameNode || !Matrix4x4.Invert(worldBefore, out Matrix4x4 back)) return (moved, applied); - - Matrix4x4 delta = back * worldAfter; - List links = _host.LinkedSlots([node], new Dictionary { [node] = worldBefore }) - .TryGetValue(node, out List? mine) ? mine : []; var hulls = new List<(SceneNode Node, Matrix4x4 Before)>(); - foreach (D3DImageHost.LinkedPlacement link in links) + foreach (D3DImageHost.LinkedPlacement link in riders) { - if (link.Hull.Source is not IFrameNode h) continue; + if (link.Hull.Source is not IFrameNode h || skip?.Invoke(link.Hull) == true + || hulls.Any(x => ReferenceEquals(x.Node, link.Hull))) + { + continue; + } hulls.Add((link.Hull, h.LocalTransform)); h.LocalTransform = TransformOps.WorldDeltaToLocal(h.WorldTransform, h.ParentWorldTransform, delta); SyncNodeMeshes(link.Hull); } - if (hulls.Count == 0) return (moved, applied); - - // A placement cannot store a scale: a resize becomes a rescaled hull, as at the end of a drag. - applied.AddRange(_host.CollisionEditing.MintPreviewedScales(hulls.Select(x => x.Node).ToList())); - applied.AddRange(Relink(links)); - foreach ((SceneNode hull, Matrix4x4 hullBefore) in hulls) - { - moved.Add((hull, hullBefore, ((IFrameNode)hull.Source!).LocalTransform)); - } - return (moved, applied); + minted = hulls.Count == 0 ? [] : [.. _host.CollisionEditing.MintPreviewedScales(hulls.Select(x => x.Node).ToList())]; + return [.. hulls.Select(x => (x.Node, x.Before, ((IFrameNode)x.Node.Source!).LocalTransform))]; } // True if any frame-graph ancestor of fn is itself selected (its cascade will move fn). @@ -261,12 +245,29 @@ public void RecordTransform(SceneNode node, Matrix4x4 before, Matrix4x4 after) // A number typed into the Transform panel comes through here, not through a drag — and the collision // an imported object was given has to follow it all the same. It used to stay where the object had // stood; past a few metres it was then no longer found as the object's, and a later delete left it in - // the file as an obstacle nobody can see. + // the file as an obstacle nobody can see. Everything is looked for from where it WAS: the change has + // already been made by the time it is recorded. if (node.Source is IFrameNode moved) { Matrix4x4 parent = moved.ParentWorldTransform; - (List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)> carried, applied) = CarryLinked(node, before * parent, after * parent); - items.AddRange(carried); + Matrix4x4 worldBefore = before * parent, worldAfter = after * parent; + List links; + if (node.Source is CollisionInstanceAdapter) + { + // A placement changed on its own: nothing rides with it, but its object's record follows. + links = _host.LinksOf([node], new Dictionary { [node] = worldBefore.Translation }); + } + else + { + links = _host.LinkedSlots([node], new Dictionary { [node] = worldBefore }) + .TryGetValue(node, out List? mine) ? mine : []; + if (links.Count > 0 && Matrix4x4.Invert(worldBefore, out Matrix4x4 back)) + { + items.AddRange(MoveRiders(links, back * worldAfter, null, out List minted)); + applied.AddRange(minted); + } + } + applied.AddRange(Relink(links)); } RecordGroupTransform(items, applied); } From 4006700c25567292c8ee3d273b9cb8c78a9010d6 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:15:50 +0300 Subject: [PATCH 066/102] fix(cars): the export's check of an existing list asks each car for its model name c236fc5 required every item of an existing vehicles.json to be an object; one whose model is not a name passed still, the archives were copied, and the list then threw on reading it. Asked for before anything is copied. --probe-car-m2o 26/26. --- src/Illusion.Assets/Cars/CarM2oExport.cs | 5 +++-- src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs | 4 ++++ 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/Illusion.Assets/Cars/CarM2oExport.cs b/src/Illusion.Assets/Cars/CarM2oExport.cs index 677cdd6..bc9e20c 100644 --- a/src/Illusion.Assets/Cars/CarM2oExport.cs +++ b/src/Illusion.Assets/Cars/CarM2oExport.cs @@ -137,8 +137,9 @@ public static string DefaultResource(string car) => // file cut short — used to be taken for "no list yet" and written over with this one car: every // earlier registration, or the package's own settings, gone, after the archives had already been // replaced. It is a refusal, and the folder is left as it was. - if (Unreadable(vehiclesFile, node => node?["vehicles"] is JsonArray listed && listed.All(v => v is JsonObject), - "a list of vehicles") is { } badList) + if (Unreadable(vehiclesFile, node => node?["vehicles"] is JsonArray listed && listed.All(v => + v is JsonObject car && car["model"] is JsonValue model && model.TryGetValue(out _)), + "a list of vehicles, each with its model name") is { } badList) { refusal = badList; return null; diff --git a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs index 01ccf1b..c5e5885 100644 --- a/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs +++ b/src/Illusion/Diagnostics/Probes/CarM2oExportProbes.cs @@ -130,6 +130,10 @@ void Check(string name, bool ok, string detail = "") CarM2oExport.ExportFrom(sources, broken, "car-test", out string? oddList) == null && oddList != null && File.ReadAllText(Path.Combine(broken, CarM2oExport.VehiclesFile)) == OddList && Directory.GetFileSystemEntries(broken).Length == 2, oddList ?? "exported"); + File.WriteAllText(Path.Combine(broken, CarM2oExport.VehiclesFile), "{ \"vehicles\": [ { \"model\": 5 } ] }"); + Check("…and one whose car has no model name", + CarM2oExport.ExportFrom(sources, broken, "car-test", out string? noModel) == null && noModel != null + && Directory.GetFileSystemEntries(broken).Length == 2, noModel ?? "exported"); CarM2oExportResult? result = CarM2oExport.ExportFrom(sources, output, CarM2oExport.DefaultResource(Name), out refused); Check("exports the clone", result != null, refused ?? ""); From 12d8193d482c739abca209446df9230805dbd633 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:29:14 +0300 Subject: [PATCH 067/102] fix(mirror): the same bytes are not written again for an entry that cannot be copied b2f3b79 left one case: a texture whose bytes already match summer's but whose manifest entry differs from a summer entry that cannot be copied fell through to the write, so every mirror rewrote the same file and reported it as refreshed. Nothing is written and nothing is reported. --probe-season-mirror 28/28. The case itself has no probe: no stock archive has such an entry. --- src/Illusion.Assets/Sds/SeasonMirror.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Illusion.Assets/Sds/SeasonMirror.cs b/src/Illusion.Assets/Sds/SeasonMirror.cs index e250a23..f32642e 100644 --- a/src/Illusion.Assets/Sds/SeasonMirror.cs +++ b/src/Illusion.Assets/Sds/SeasonMirror.cs @@ -561,6 +561,8 @@ bool Bring(string name) manifest.RemoveEntry(name); return CopyEntry(from.Manifest, manifest, summerDir, winterDir, name); } + // An entry that differs and cannot be copied leaves nothing to do when the bytes are the same. + if (sameBytes) return false; AtomicFile.WriteAllBytes(target, bytes); return true; } From c576dfe66983e567a26f6fd6e6c13080c47b1061 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 01:34:22 +0300 Subject: [PATCH 068/102] fix(import): a rename moves the collision record by whose it is, not by there being a namesake c5ec22a made the record follow a rename only when no other object bore the old name. That is the wrong question. An imported object renamed onto a stock object's name brought its record there, and the undo then left it behind: the old name had a second bearer. The same for an object renamed away from a name it shares - its collision stayed with the namesake. Whose the record is is now asked before the name changes, with the answer every move, delete and duplicate already goes by: the one bearer, or among several the one the record fits. The asking moved from the property catalog (which cannot see the collision layer) to PropertyEditController; the catalog's setter is the plain one again. An entry from before places were kept counts in the fit by how near its hull stands to the object; skipped, such a record fitted every bearer equally and went to the first in the file. Also: a click on the gizmo that moves nothing no longer brings an old record up to date and leaves an undo step with nothing to show for it. --probe-object-import-live 54/54 (6 new: onto a stock object's name and back, the namesake renamed away, the object renamed away, both undone), --probe-object-transplant 55/55, --probe-prop-catalog 12/12, --probe-editor-tools 23/23. Not exercised: two bearers of a name whose record has only old entries. --- .../Properties/FramePropertyCatalog.cs | 29 ++-------- .../Probes/ObjectImportLiveProbes.cs | 54 +++++++++++++++++++ src/Illusion/Viewport/D3DImageHost.cs | 51 +++++++++++++++--- .../Viewport/PropertyEditController.cs | 27 +++++++++- .../Viewport/TransformEditController.cs | 4 +- 5 files changed, 130 insertions(+), 35 deletions(-) diff --git a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs index 687aaa0..a5f63c8 100644 --- a/src/Illusion.Assets/Properties/FramePropertyCatalog.cs +++ b/src/Illusion.Assets/Properties/FramePropertyCatalog.cs @@ -23,7 +23,7 @@ public static IReadOnlyList Build(FrameNodeAdapter node) FrameObjectBase o = node.Frame; var c = new GroupCollector(); - AddBase(o, node, c); + AddBase(o, c); // if-chain, not a switch — the vendor hierarchy makes many types share a base (SingleMesh:Joint, // Model:SingleMesh, Area:Joint …); each applicable layer contributes its own groups. @@ -44,11 +44,11 @@ public static IReadOnlyList Build(FrameNodeAdapter node) } // ── Common (every frame object) ── - private static void AddBase(FrameObjectBase o, FrameNodeAdapter node, GroupCollector c) + private static void AddBase(FrameObjectBase o, GroupCollector c) { // Name is always editable: the name-table rewrite on save (SdsWriter.SaveFrameNameTable) keeps an // on-table object's listed name in sync. The type + triangle count live in the tab's header card. - c.AddCommon("Identity", HashNameDesc("Base.Name", "Name", () => o.Name, name => Rename(o, node, name), + c.AddCommon("Identity", HashNameDesc("Base.Name", "Name", () => o.Name, name => o.Name.Set(name), "The FNV64 hash is re-derived from the name.")); // Frame table — the flag bits (1..4096) and whether the object is listed in the frame table at all. @@ -64,29 +64,6 @@ private static void AddBase(FrameObjectBase o, FrameNodeAdapter node, GroupColle // The local transform is likewise absent — the gizmo and the Position/Rotation/Scale fields own it. } - // The collision an imported object was given is tied to it by its NAME (Sds.ImportLinks) — the only handle - // a static object has. A rename takes the record along; an undo of the rename comes through here as well. - private static void Rename(FrameObjectBase o, FrameNodeAdapter node, string name) - { - string old = o.Name.String; - // Only for the one object that bears the name. With two of a name — an earlier rename onto a name - // that was taken — the record under it belongs to one of them and cannot be told to follow the - // other: renaming the newcomer away again must leave it where it is. - bool sole = !node.Document.Frame.FrameObjects.Values.OfType() - .Any(f => !ReferenceEquals(f, o) && string.Equals(f.Name.String, old, StringComparison.Ordinal)); - o.Name.Set(name); - if (!sole) return; - try - { - Sds.ImportLinks.Rename(MafiaEnvironment.ExtractedDir(node.Document.SourceArchive), old, name); - } - catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException - or InvalidOperationException or NullReferenceException) - { - // no game environment (a probe, a loose file): there is no link file to keep in step - } - } - private static void AddJoint(FrameObjectJoint j, GroupCollector c) { FrameObjectJoint.NodeStruct[]? data = j.Data; diff --git a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs index 0f3e9ce..d79daaa 100644 --- a/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs +++ b/src/Illusion/Diagnostics/Probes/ObjectImportLiveProbes.cs @@ -319,6 +319,60 @@ Vector3 Relative() => Matrix4x4.Invert(moved.WorldTransform, out Matrix4x4 inv) sb.AppendLine(" (a second copy could not be imported — the rename-onto step was skipped: " + why + ")"); } + // ── a name shared with an object that has NO record: the record still goes with its own object ── + { + void GiveName(SceneNode which, string to) + { + Domain.Properties.PropertyDescriptor field = ((Domain.Properties.IPropertySource)which.Source!).GetPropertyGroups() + .SelectMany(g => g.Properties).First(p => p.Id == "Base.Name"); + host.CommitPropertyEdit(which, field, field.Get(), new Domain.Properties.HashNameValue(0, to)); + } + // The stock mesh the copy was made of: same scene, named `source`, nothing recorded under that name. + SceneNode? namesake = Nodes(host).FirstOrDefault(n => !ReferenceEquals(n, node) + && n.Source is FrameNodeAdapter f && n.Source is not CollisionInstanceAdapter && f.Frame.Name.String == source + && ReferenceEquals(n.OwningDocumentNode(), node.OwningDocumentNode())); + if (namesake != null && ImportLinks.HullsOf(dir, source).Count == 0) + { + IReadOnlyList own = [.. ImportLinks.HullsOf(dir, "probe_live_renamed")]; + GiveName(node, source); + Check("renamed onto the name of a stock object, the imported one brings its record along", + ImportLinks.HullsOf(dir, source).SequenceEqual(own) && ImportLinks.HullsOf(dir, "probe_live_renamed").Count == 0 + && host.LinkedCollisionNodes(node).Count == given && host.LinkedCollisionNodes(namesake).Count == 0, + $"{host.LinkedCollisionNodes(node).Count} with the imported one, {host.LinkedCollisionNodes(namesake).Count} with the stock one"); + host.Undo(); + Check("…and the undo takes the record back from the shared name", + ImportLinks.HullsOf(dir, "probe_live_renamed").SequenceEqual(own) && ImportLinks.HullsOf(dir, source).Count == 0 + && host.LinkedCollisionNodes(node).Count == given, + $"{ImportLinks.HullsOf(dir, "probe_live_renamed").Count} under its own name, {ImportLinks.HullsOf(dir, source).Count} under the shared one"); + host.Redo(); + + // Two of a name now, the record the imported one's. + GiveName(namesake, "probe_live_stock"); + Check("the stock namesake renamed away leaves the record with the object it belongs to", + ImportLinks.HullsOf(dir, source).SequenceEqual(own) && ImportLinks.HullsOf(dir, "probe_live_stock").Count == 0 + && host.LinkedCollisionNodes(node).Count == given); + host.Undo(); + Check("…and is the stock object's name again after the undo, the record untouched", + ((FrameNodeAdapter)namesake.Source!).Frame.Name.String == source && ImportLinks.HullsOf(dir, source).SequenceEqual(own)); + + GiveName(node, "probe_live_c"); + Check("the imported object renamed away from the shared name takes its record with it", + ImportLinks.HullsOf(dir, "probe_live_c").SequenceEqual(own) && ImportLinks.HullsOf(dir, source).Count == 0 + && host.LinkedCollisionNodes(node).Count == given && host.LinkedCollisionNodes(namesake).Count == 0, + $"{ImportLinks.HullsOf(dir, "probe_live_c").Count} under the new name, {ImportLinks.HullsOf(dir, source).Count} left under the shared one"); + host.Undo(); + host.Undo(); + Check("…and with both renames undone everything is as it was", + ImportLinks.HullsOf(dir, "probe_live_renamed").SequenceEqual(own) && ImportLinks.HullsOf(dir, source).Count == 0 + && ImportLinks.HullsOf(dir, "probe_live_c").Count == 0 && host.LinkedCollisionNodes(node).Count == given + && ((FrameNodeAdapter)node.Source!).Frame.Name.String == "probe_live_renamed"); + } + else + { + sb.AppendLine(" (no stock namesake in the same scene — the shared-name steps were skipped)"); + } + } + // ── delete takes the hull; undo brings both back ── host.Selection.SetSelection([node], node); host.DeleteSelected(); diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index 95de50f..76e76c7 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -397,6 +397,35 @@ void Walk(SceneNode node) return found; } + /// + /// Whether the record of given collision kept under this object's name is THIS object's — for a rename, + /// which has to take the record along when it is, and leave it where it is when it is a namesake's. + /// With one object of the name it is that object's; with several, the one the record fits (the same + /// answer every move, delete and duplicate goes by). + /// + /// The archive's working copy, where the record is kept. + internal bool OwnsImportLinks(SceneNode node, out string dir) + { + dir = ""; + if (node.Source is not FrameNodeAdapter frame || node.Source is CollisionInstanceAdapter + || node.OwningDocumentNode() is not { Source: SceneDocumentAdapter scene } documentNode) + { + return false; + } + try + { + dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); + Assignments(scene, documentNode, _ => null, null, out var owners); + return owners.TryGetValue(frame.Frame.Name.String, out Formats.Frames.ObjectTypes.FrameObjectBase? owner) + && ReferenceEquals(owner, frame.Frame); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException + or InvalidOperationException or NullReferenceException) + { + return false; // no game environment (a loose file): there is no link file to keep in step + } + } + private static bool IsSelfOrUnder(Formats.Frames.ObjectTypes.FrameObjectBase frame, Formats.Frames.ObjectTypes.FrameObjectBase root) { int guard = 0; @@ -410,14 +439,23 @@ private static bool IsSelfOrUnder(Formats.Frames.ObjectTypes.FrameObjectBase fra // Every placement of an archive that some imported object's record claims, worked out once: each recorded // entry bids for the placements of its hull, nearest first, and a placement goes to the first bid only. private List Assignments(SceneDocumentAdapter scene, SceneNode documentNode, - Func lookFrom, IReadOnlyDictionary? stoodAt) + Func lookFrom, IReadOnlyDictionary? stoodAt) => + Assignments(scene, documentNode, lookFrom, stoodAt, out _); + + /// The object each recorded name belongs to — for every name that has a record and + /// an object bearing it, whether or not any of its placements was found. + private List Assignments(SceneDocumentAdapter scene, SceneNode documentNode, + Func lookFrom, IReadOnlyDictionary? stoodAt, + out Dictionary owners) { + owners = new Dictionary(StringComparer.Ordinal); var assigned = new List(); string dir = Assets.MafiaEnvironment.ExtractedDir(scene.SourceArchive); IReadOnlyDictionary> links = Assets.Sds.ImportLinks.All(dir); - if (links.Count == 0 || FindCollisionLayer(documentNode) is not { } layer) return assigned; + if (links.Count == 0) return assigned; - List placements = [.. layer.Children]; + // Without a collision layer nothing is placed, but a record is still somebody's. + List placements = FindCollisionLayer(documentNode) is { } layer ? [.. layer.Children] : []; var byHull = new Dictionary>(); for (int i = 0; i < placements.Count; i++) { @@ -433,7 +471,6 @@ Vector3 Stands(int i) => stoodAt != null && stoodAt.TryGetValue(placements[i], o // two, it is the one the record FITS — whose matrix puts the recorded places where placements of // those hulls stand — and not simply the first in the file: by file order, renaming an earlier object // onto a later one's name handed it the later one's collision. - var owners = new Dictionary(StringComparer.Ordinal); foreach (var named in scene.Frame.FrameObjects.Values.OfType() .Where(f => f.Name.String is { Length: > 0 } n && links.ContainsKey(n)).GroupBy(f => f.Name.String, StringComparer.Ordinal)) { @@ -449,8 +486,10 @@ float Misfit(Formats.Frames.ObjectTypes.FrameObjectBase bearer) float total = 0f; foreach (Assets.Sds.ImportLinks.Link link in links[named.Key]) { - if (link.At is not { } place) continue; - Vector3 expected = Vector3.Transform(place, standing); + // An entry from before places were kept has only the hull to go by: how near a placement + // of it stands to the object. Skipped, a record made of such entries fitted every bearer + // equally and went to the first in the file. + Vector3 expected = link.At is { } place ? Vector3.Transform(place, standing) : standing.Translation; float nearest = 1e6f; if (byHull.TryGetValue(link.Hull, out List? candidates)) { diff --git a/src/Illusion/Viewport/PropertyEditController.cs b/src/Illusion/Viewport/PropertyEditController.cs index 5794ee6..408eab9 100644 --- a/src/Illusion/Viewport/PropertyEditController.cs +++ b/src/Illusion/Viewport/PropertyEditController.cs @@ -19,7 +19,7 @@ internal sealed class PropertyEditController public void Commit(SceneNode node, PropertyDescriptor descriptor, object? before, object? after) { if (descriptor.Set is null || Equals(before, after)) return; - descriptor.Set(after); + SetAndKeepLinks(node, descriptor, after); PropagateNameEdit(node, descriptor); _host.Editing.History.Push(new PropertyEdit(this, node, descriptor, before, after)); _host.Persistence.MarkFrameModified(node); @@ -32,7 +32,7 @@ public void Commit(SceneNode node, PropertyDescriptor descriptor, object? before private void ApplyRecorded(SceneNode node, PropertyDescriptor descriptor, object? value) { if (descriptor.Set is null || !_host.Tree.IsInScene(node)) return; - descriptor.Set(value); + SetAndKeepLinks(node, descriptor, value); PropagateNameEdit(node, descriptor); // update the tree row/name BEFORE re-select so the rebuilt panel is correct _host.Persistence.MarkFrameModified(node); // undo/redo re-dirties the frame vs. the last save _host.Selection.SetSelection(new[] { node }, node); @@ -40,6 +40,29 @@ private void ApplyRecorded(SceneNode node, PropertyDescriptor descriptor, object _host.RaiseSelectionPropertiesChanged(); } + // The collision an imported object was given is tied to it by its NAME (Sds.ImportLinks) — the only handle + // a static object has — so a rename takes the record along, and so does the undo of one, which comes + // through here as well. Only when the record is this object's: with two objects of a name (one renamed + // onto a name that was taken) the record under it belongs to one of them, and whose it is is asked + // BEFORE the name changes — by which of them it fits, not by there being a namesake. Asked by namesake + // alone, an object that had brought its record onto a taken name could not take it away again. + private void SetAndKeepLinks(SceneNode node, PropertyDescriptor descriptor, object? value) + { + string? old = null, dir = null; + if (descriptor.Id == "Base.Name" && descriptor.Get() is HashNameValue was && _host.OwnsImportLinks(node, out string kept)) + { + old = was.Name; + dir = kept; + } + descriptor.Set!(value); + if (old != null && dir != null && descriptor.Get() is HashNameValue now) + { + // Not over a record the new name already has: this object then gives up its tie, and its record + // stays under the old name for the undo to find. + Assets.Sds.ImportLinks.Rename(dir, old, now.Name); + } + } + // Side effects of a name / on-name-table edit: the visible tree-row name and the name-table dirty flag (those // fields live in the FrameNameTable, not the frame stream, so the next save must rewrite it). private void PropagateNameEdit(SceneNode node, PropertyDescriptor descriptor) diff --git a/src/Illusion/Viewport/TransformEditController.cs b/src/Illusion/Viewport/TransformEditController.cs index 12bac6e..6b42330 100644 --- a/src/Illusion/Viewport/TransformEditController.cs +++ b/src/Illusion/Viewport/TransformEditController.cs @@ -223,7 +223,9 @@ public void GizmoEndDrag() List mints = [.. _host.CollisionEditing.MintPreviewedScales(nodes)]; // After the mints in the composite, so before them on undo: the record goes back to the old hashes // while the placements still carry the new ones, and then the placements follow. - mints.AddRange(Relink(_dragLinks)); + // Only for a drag that moved something: a click on the gizmo is not an edit, and a record from before + // places were kept would be brought up to date by it and leave an undo step that undoes nothing seen. + if (_gizmoMoved) mints.AddRange(Relink(_dragLinks)); _dragLinks = []; var items = new List<(SceneNode Node, Matrix4x4 Before, Matrix4x4 After)>(_dragGroup.Count); From b0a8984665756231592997f006525bf93a2ae69d Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:06:30 +0300 Subject: [PATCH 069/102] feat(bridge): a skinned mesh can gain geometry; two levels of one frame in one push A push that added vertices to a car body was refused: the remap pools of a skinned model were treated as fixed. They are rebuilt from the pushed skin (SkinState, BlendPoolTables), with the undo taking the pools back. Two levels of detail of one frame pushed together are computed one after the other (TryApplyAfter): each used to carry the other level as it was and the second undid the first. Only the first level speaks for where the frame stands. --probe-remap-pools shubert_38, --probe-bridge-skin. Not seen in game. --- .../Bridge/BridgeMeshApplier.cs | 433 ++++++-- .../Bridge/BridgeMeshExporter.cs | 7 +- src/Illusion.Assets/Bridge/SkinState.cs | 125 +++ src/Illusion.Assets/Frames/BlendPoolTables.cs | 135 +++ .../Frames/ObjectTypes/FrameObjectModel.cs | 9 + .../Bridge/BridgeSessionController.cs | 59 +- .../Diagnostics/Probes/BridgeSkinProbes.cs | 15 +- .../Diagnostics/Probes/RemapPoolProbes.cs | 960 ++++++++++++++++++ 8 files changed, 1635 insertions(+), 108 deletions(-) create mode 100644 src/Illusion.Assets/Bridge/SkinState.cs create mode 100644 src/Illusion.Assets/Frames/BlendPoolTables.cs create mode 100644 src/Illusion/Diagnostics/Probes/RemapPoolProbes.cs diff --git a/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs b/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs index 209bb4a..9f5a4b1 100644 --- a/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs +++ b/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs @@ -68,6 +68,14 @@ public sealed class ApplyResult internal Vector3 OldDecompressionOffset; internal float OldDecompressionFactor; + /// A skinned model's pools, face ranges, hit boxes and skeleton tables before the push and + /// as the push leaves them. They go in and out with the buffers: old vertex bytes read through new + /// pools name other bones. + internal SkinState? SkinBefore; + + /// + internal SkinState? SkinAfter; + /// Pre-push packed vertex bytes (diagnostics/probes; also the undo payload). public byte[] OldVertexData { get; internal set; } = null!; @@ -125,6 +133,7 @@ public sealed class ApplyResult public void ApplyNew() { ApplyBuffers(); + if (Frame is FrameObjectModel skinned) SkinAfter?.Apply(skinned); // …and the per-BONE boxes, which are derived from the vertices that just moved. Measured on 88 // cars: a box bounds every vertex with any weight on its bone, in that bone's own space, and @@ -194,6 +203,32 @@ public void RestoreOriginal() Rebuild.IndexBuffer.SetData(Rebuild.OldIndexData); Document?.MarkIndexBufferDirty(Rebuild.IndexBuffer.Hash); } + // …and the skin as it was, boxes included: ApplyNew rebuilt those from the new geometry, and the + // old geometry is entitled to the ones it had. + if (Frame is FrameObjectModel skinned) SkinBefore?.Apply(skinned); + } + } + + /// + /// for an object whose FRAME this push has already produced results for — the + /// other level of the same mesh. Both levels are one frame: they are packed against one quantization + /// lattice, share its bounds and, on a model, its pools. A result is computed against the frame as it + /// stands and carries "the other level as it was", so two results computed side by side each undo the + /// other when applied. This one is computed with the ones in place (and the + /// frame put back afterwards): the caller applies them in the same order and undoes them in reverse. + /// + public static ApplyResult? TryApplyAfter(IReadOnlyList earlier, IFrameNode node, + MeshObjectPayload payload, out string? skipReason, int lod = 0) + { + ArgumentNullException.ThrowIfNull(earlier); + foreach (ApplyResult applied in earlier) applied.ApplyNew(); + try + { + return TryApply(node, payload, out skipReason, lod); + } + finally + { + for (int i = earlier.Count - 1; i >= 0; i--) earlier[i].RestoreOriginal(); } } @@ -218,7 +253,13 @@ public void RestoreOriginal() // remap must not have every later push refused on account of it — the question below is whether THIS // push breaks it, not whether it was whole to begin with. FrameObjectModel? skinned = node is FrameNodeAdapter { Frame: FrameObjectModel m } ? m : null; - bool resolvedBefore = skinned != null && SdsMeshLoader.GlobalBoneIds(skinned) != null; + bool resolvedBefore = skinned != null && SdsMeshLoader.GlobalBoneIds(skinned, lod) != null; + + // The paths below work a skinned model's pools, face ranges and skeleton tables out by writing them. + // That is taken as the push's result and then taken BACK: a push that is refused must leave nothing + // behind, and one that is accepted is the caller's to commit — and to undo. What was written rides in + // the result and goes in with ApplyNew, out with RestoreOriginal. + SkinState? skinBefore = skinned != null ? SkinState.Take(skinned) : null; // A level left with a 32-bit index buffer — something an earlier version of this code wrote for a mesh // over 65535 vertices, and the game does not draw — is rebuilt whatever the push changed: the rebuild @@ -230,6 +271,16 @@ public void RestoreOriginal() { result = TryApplyRebuild(node, payload, out skipReason, lod); } + if (skinned != null && skinBefore != null) + { + if (result is { Unchanged: false }) + { + result.SkinBefore = skinBefore; + result.SkinAfter = SkinState.Take(skinned); + } + skinBefore.Apply(skinned); + } + // A push that changed nothing has nothing to break, and its result carries no buffers to apply — // ApplyNew on one of those is a null reference, not a check. if (result == null || skinned == null || !resolvedBefore || result.Unchanged) return result; @@ -243,15 +294,15 @@ public void RestoreOriginal() // Applied, checked and put back: the caller is the one that commits, and a push that would break the // skin has to be refused while the modeller is still in Blender and can split the vertex groups. result.ApplyNew(); - bool resolvesAfter = SdsMeshLoader.GlobalBoneIds(skinned) != null; + bool resolvesAfter = SdsMeshLoader.GlobalBoneIds(skinned, result.Lod) != null; string broke = resolvesAfter ? "" : SdsMeshLoader.DescribeBoneRemap(skinned); result.RestoreOriginal(); if (resolvesAfter) return result; skipReason = "this push would leave a skin the game cannot read, though the editor would still draw " - + "it: " + broke + ". A bone id has to fit the remap pool of the face group that draws it, and " - + "the pools are fixed at 64 entries in all. Give the affected faces one vertex group instead of " - + "two, or move them onto the material their bones already belong to, and push again."; + + "it: " + broke + ". A bone id has to fit the remap pool of the face group that draws it. " + + "Give the affected faces one vertex group instead of two, or move them onto the material " + + "their bones already belong to, and push again."; return null; } @@ -307,7 +358,10 @@ private static int UngroupedVertices(IFrameNode node, MeshObjectPayload payload) private static bool NeedsRebuild(string reason) => reason.StartsWith("topology changed", StringComparison.Ordinal) - || reason.Contains("source vertex index out of range", StringComparison.Ordinal); + || reason.Contains("source vertex index out of range", StringComparison.Ordinal) + // A re-weight that puts one vertex under two pools keeps its vertex count only on paper: the + // vertex has to become two, and making vertices is the rebuild's job. + || reason.StartsWith(SharedAcrossPoolsReason, StringComparison.Ordinal); /// Computes the count-preserving application of to /// 's mesh. Null with a reason when it cannot apply (topology changed, @@ -588,8 +642,12 @@ private static bool NeedsRebuild(string reason) => // whose split table and OPCODE partition the native core builds (one split + one burst per // material — see FrameLOD.CreateRebuilt). Lower LODs and the separate collision resource keep // their old shape — the caller warns the user once. + // + // poolOfSlot is the second pass of a skinned rebuild: the pool each surviving material slot was + // planned to draw from. A corner then belongs to its pool as much as to its normal and UV, so a + // vertex two pools would have shared comes out as one vertex per pool. private static ApplyResult? TryApplyRebuild(IFrameNode node, MeshObjectPayload payload, - out string? skipReason, int lod = 0) + out string? skipReason, int lod = 0, int[]? poolOfSlot = null) { skipReason = null; // A skinned model may be re-topologised: below, its remap pools AND its per-bone face ranges @@ -676,7 +734,7 @@ private static bool NeedsRebuild(string reason) => // agree on pos/normal/uv0 can still differ in channels Blender never saw (colors, extra UV // sets, damage groups), and merging them would corrupt those. Only Blender-born corners // (orig −1) deduplicate purely by attributes. - var keyToSplit = new Dictionary<(int Orig, uint Welded, int NormalKey, uint UvKey), int>(loops); + var keyToSplit = new Dictionary<(int Orig, uint Welded, int NormalKey, uint UvKey, int Pool), int>(loops); var positions = new List(); var normals = new List(); var uvs = new List(); @@ -696,7 +754,9 @@ private static bool NeedsRebuild(string reason) => ? payload.LoopOrigIndex[i] : -1; Vector3 normal = payload.LoopNormals[i]; Vector2 uv = new(payload.LoopUvs[i].X, 1f - payload.LoopUvs[i].Y); - var key = (orig, welded, PackNormalKey(normal), PackUvKey(uv)); + int keptSlot = slotRemap[payload.FaceMaterials[i / 3]]; + int pool = poolOfSlot != null && keptSlot >= 0 && keptSlot < poolOfSlot.Length ? poolOfSlot[keptSlot] : 0; + var key = (orig, welded, PackNormalKey(normal), PackUvKey(uv), pool); if (!keyToSplit.TryGetValue(key, out int split)) { split = positions.Count; @@ -931,6 +991,30 @@ private static bool NeedsRebuild(string reason) => SkeletonData? renderRig = null; if (newGlobal != null && frame is FrameObjectModel rebuiltModel) { + // The pools FIRST, and only as a plan: everything below writes into the model, and a push the + // pools cannot answer for has to be refused before any of it has. + PoolPlan? pools = PlanPools(rebuiltModel, outVerts, newGlobal, newIndexData, newMats, existingMats, + decoded.Lod, out string? blendReason); + if (pools == null) + { + skipReason = blendReason; + return null; + } + if (pools.SharedAcrossPools) + { + // A vertex on the seam between two face groups that read different pools. It used to be the + // modeller's job to cut the mesh there; the cut is mechanical, so it is made here — the same + // push again, with each corner keyed by the pool its face draws from. + if (poolOfSlot != null) + { + skipReason = SharedAcrossPoolsReason + ", and giving each its own copy did not separate " + + "them — split the mesh along that material boundary and push again"; + return null; + } + return TryApplyRebuild(node, payload, out skipReason, lod, + [.. pools.Groups.Select(g => (int)g.AssignedPoolIndex)]); + } + // The per-bone face ranges are ONE table on the model — there is no copy per level, and the // ranges it holds address LOD0's index buffer. Rebuilding it from a coarser level's indices // would point the physics splits at triangles of the wrong mesh, so a push into any other @@ -952,12 +1036,9 @@ private static bool NeedsRebuild(string reason) => for (int k = 0; k < 4; k++) renderWeights[(v * 4) + k] = outVerts[v].BoneWeights[k]; renderRig = SdsMeshLoader.RigOf(rebuiltModel); - if (!RemapBlendInfo(rebuiltModel, outVerts, newGlobal, newIndexData, newMats, existingMats, - decoded.Lod, out string? blendReason)) - { - skipReason = blendReason; - return null; - } + // Last, once nothing can refuse any more: the face ranges above were matched against the table + // as it shipped, and this is what moves the table. + CommitPools(rebuiltModel, pools); } byte[] newData = VertexCompressor.CompressBuffer( @@ -1489,62 +1570,144 @@ private static bool RebuildMeshSplits( } /// - /// Re-points a re-topologised skinned model's vertices at the remap pools it already ships with, and says - /// which pool each new face group draws from. Returns false with a reason when the pools cannot answer for - /// the geometry that came back. + /// What one level's remap pools have to be for the geometry that came back: the pools, which of them each + /// face group draws from, and where every entry of the old table has moved to. Worked out without + /// touching the model, so a push that turns out not to fit leaves it exactly as it was. + /// + private sealed class PoolPlan + { + internal int Level; + internal byte[] Sizes = []; + internal byte[] Remap = []; + internal FrameBlendInfo.SkinnedMaterialInfo[] Groups = []; + + /// The table before and after, pool by pool — an old entry keeps its pool and its place in + /// it, so its new index is the pool's new start plus the same offset. + internal int[] OldStart = []; + internal byte[] OldSizes = []; + internal int[] NewStart = []; + + /// A pool was made longer, or a new one opened: the table moved and everything that + /// describes it has to follow. + internal bool Grew; + + /// Some vertex is drawn by two face groups that read their bones from different pools. It + /// carries one set of ids, so it has to become two vertices before the plan can be carried out. + internal bool SharedAcrossPools; + + internal int Reindex(int oldIndex) + { + for (int p = 0; p < OldSizes.Length; p++) + { + if (oldIndex >= OldStart[p] && oldIndex < OldStart[p] + OldSizes[p]) + return NewStart[p] + (oldIndex - OldStart[p]); + } + return oldIndex; + } + } + + private const string SharedAcrossPoolsReason = + "a vertex is shared by two materials that read their bones from different pools"; + + /// + /// Re-points a skinned model's vertices at its remap pools and writes the pools back — the count-preserving + /// path's whole answer, where no vertex can be added. False with a reason when the pools cannot be made to + /// answer for the weights that came back; a vertex that would have to be split says so in a form the + /// caller turns into a rebuild. + /// + private static bool RemapBlendInfo( + FrameObjectModel model, Vertex[] vertices, byte[] globalOf, uint[] indices, + MaterialStruct[] newMats, MaterialStruct[] oldMats, int lod, out string? reason) + { + PoolPlan? plan = PlanPools(model, vertices, globalOf, indices, newMats, oldMats, lod, out reason); + if (plan == null) return false; + if (plan.SharedAcrossPools) + { + reason = SharedAcrossPoolsReason + " — the mesh has to be rebuilt to give each its own copy"; + return false; + } + CommitPools(model, plan); + return true; + } + + /// + /// Works out the remap pools a re-topologised (or re-weighted) level needs, and localizes the vertices' + /// bone ids against them. Null with a reason when no arrangement of pools the game is known to read can + /// answer for the geometry. + /// + /// The shipped pools are the starting point and are never reshuffled: a car does not put its whole rig in + /// one pool — shubert_38 ships 59 bones in pool 0 and 33 in pool 1 for 83 bones total — and replacing that + /// with one pool over every bone is what tore a repacked car apart. A face group that fits a shipped pool + /// keeps it, which leaves the skin channel byte-identical wherever nothing moved. + /// /// - /// The pools themselves are NOT rebuilt. A car does not put its whole rig in one pool — shubert_38 ships - /// 59 bones in pool 0 and 33 in pool 1 for 83 bones total (--probe-skinning) — and replacing that - /// with a single pool over every bone is what tore a repacked car apart: a draw can only reach so far into - /// the palette, so ids past the end came back as garbage transforms. Keeping the shipped pools also keeps - /// the skin channel byte-identical for every vertex that kept its material, which is the common case. + /// A face group whose bones no shipped pool holds together GROWS the pool that is missing fewest of them, + /// by appending — so every id already written against that pool still means the same bone — or, when no + /// pool has the room, opens a new one. What that asks of the rest of the file is measured + /// (--probe-remap-pools, 353 models, 650 levels, no exception): the skeleton's count per level, its + /// usage and reference arrays and its level masks are all derived from the pools and are derived again by + /// , and a split's BlendIndex is a position in level 0's + /// table and moves with the entry it names. The ceilings are the shipped ones: no pool anywhere holds more + /// than bones, no table more than entries. /// /// - private static bool RemapBlendInfo( + private static PoolPlan? PlanPools( FrameObjectModel model, Vertex[] vertices, byte[] globalOf, uint[] indices, MaterialStruct[] newMats, MaterialStruct[] oldMats, int lod, out string? reason) { reason = null; FrameBlendInfo blend; try { blend = model.GetBlendInfoObject(); } - catch (Exception) { reason = "the model's blend info cannot be read"; return false; } + catch (Exception) { reason = "the model's blend info cannot be read"; return null; } FrameBlendInfo.BoneIndexInfo[] lods = blend.BoneIndexInfos ?? []; - if (lods.Length == 0) { reason = "the model carries no remap pools"; return false; } + if (lods.Length == 0) { reason = "the model carries no remap pools"; return null; } // The edited level's own pools — each level has its own palette and its own face groups. int level = Math.Clamp(lod, 0, lods.Length - 1); FrameBlendInfo.BoneIndexInfo edited = lods[level]; - byte[] sizes = edited.BonesPerRemapPool ?? []; - byte[] remap = edited.BoneRemapIDs ?? []; + byte[] oldSizes = edited.BonesPerRemapPool ?? []; + byte[] oldRemap = edited.BoneRemapIDs ?? []; FrameBlendInfo.SkinnedMaterialInfo[] oldGroups = edited.SkinnedMaterialInfo ?? []; // Pool p is a run of sizes[p] ids in the flat remap table. - var poolStart = new int[sizes.Length]; + var oldStart = new int[oldSizes.Length]; int poolCount = 0, at = 0; - for (int p = 0; p < sizes.Length; p++) + for (int p = 0; p < oldSizes.Length; p++) { - poolStart[p] = at; - at += sizes[p]; - if (sizes[p] > 0) poolCount = p + 1; + oldStart[p] = at; + at += oldSizes[p]; + if (oldSizes[p] > 0) poolCount = p + 1; } - if (at > remap.Length) { reason = "the model's remap pools overrun its remap table"; return false; } - if (poolCount == 0) { reason = "the model carries no remap pools"; return false; } + if (at > oldRemap.Length) { reason = "the model's remap pools overrun its remap table"; return null; } + if (poolCount == 0) { reason = "the model carries no remap pools"; return null; } - var localOf = new Dictionary[poolCount]; - sizes = (byte[])sizes.Clone(); - for (int p = 0; p < poolCount; p++) + var pools = new List[oldSizes.Length]; + var localOf = new Dictionary[oldSizes.Length]; + for (int p = 0; p < oldSizes.Length; p++) { - var map = new Dictionary(sizes[p]); - for (int i = 0; i < sizes[p]; i++) map.TryAdd(remap[poolStart[p] + i], (byte)i); + pools[p] = [.. oldRemap.AsSpan(oldStart[p], oldSizes[p])]; + var map = new Dictionary(oldSizes[p]); + for (int i = 0; i < pools[p].Count; i++) map.TryAdd(pools[p][i], (byte)i); localOf[p] = map; } // A slot that kept its material keeps that material's pool — draw order is what ties a face group to - // a pool, so the shipped answer is the right one wherever it still applies. + // a pool, so the shipped answer is the right one wherever it still applies. A material drawn by two + // slots (279 shipped levels do that) is matched occurrence for occurrence. var groups = new FrameBlendInfo.SkinnedMaterialInfo[newMats.Length]; + var seen = new Dictionary(); for (int slot = 0; slot < newMats.Length; slot++) { - int was = Array.FindIndex(oldMats, m => m.MaterialHash == newMats[slot].MaterialHash); + ulong hash = newMats[slot].MaterialHash; + int nth = seen.GetValueOrDefault(hash); + seen[hash] = nth + 1; + int was = -1; + for (int old = 0, hit = 0; old < oldMats.Length; old++) + { + if (oldMats[old].MaterialHash != hash) continue; + was = old; + if (hit++ == nth) break; + } FrameBlendInfo.SkinnedMaterialInfo donor = was >= 0 && was < oldGroups.Length ? oldGroups[was] : new FrameBlendInfo.SkinnedMaterialInfo { AssignedPoolIndex = 0, NumWeightsPerVertex = 1 }; @@ -1556,10 +1719,10 @@ private static bool RemapBlendInfo( } // Which bones each slot actually draws, and how many influences its heaviest vertex carries. - var bonesOfSlot = new HashSet[newMats.Length]; + bool grew = false; for (int slot = 0; slot < newMats.Length; slot++) { - HashSet set = bonesOfSlot[slot] = []; + var set = new HashSet(); int influences = 1; int from = newMats[slot].StartIndex; int to = Math.Min(from + (newMats[slot].NumFaces * 3), indices.Length); @@ -1581,53 +1744,87 @@ private static bool RemapBlendInfo( // A slot whose bones its inherited pool cannot name (Blender moved faces between materials, or // the slot is new) takes any pool that can. - if (set.All(b => localOf[groups[slot].AssignedPoolIndex].ContainsKey(b))) continue; + int inherited = groups[slot].AssignedPoolIndex; + if (set.All(b => localOf[inherited].ContainsKey(b))) continue; int fit = -1; for (int p = 0; p < poolCount && fit < 0; p++) if (set.All(b => localOf[p].ContainsKey(b))) fit = p; if (fit >= 0) { groups[slot].AssignedPoolIndex = (byte)fit; continue; } - // No pool has them all — so GROW one. A pool is just "the bones this face group may name", and a - // vertex addresses it with a byte; the shipped data says the engine's real limit is per pool and - // not on the total, since cars run their totals to 108 entries while no single pool anywhere - // exceeds 60 (--probe-bullets). So the bones that are missing get appended to the pool that is - // missing fewest, and only a pool that would pass 60 is refused. - // - // This is what a cube weighted to a bonnet AND its deform bone needs: the two are not in one - // pool on any car, and until this existed the push either refused or — worse, before the guard — - // wrote an id past the end of the pool, which the editor drew correctly and the game placed - // somewhere else entirely. - // NOT YET. Growing a pool is more than lengthening the remap table: the SKELETON carries the same - // total — measured on 173 of 173 LODs, its blend-id count equals the sum of the pool sizes and - // its usage array is exactly that long — and what belongs in the new usage entries has not been - // measured. Writing the longer table alone leaves the two halves disagreeing, and that is worse - // than refusing: the editor reads the pools directly and shows the part in its right place while - // the game reads through the skeleton's mapping and puts it somewhere else. That was reported - // twice, and the second time it was this code that caused it. - // A model has dozens of groups and the refusal is about ONE material, so saying only that a pool - // cannot cover it leaves the modeller hunting blind. Name the material, its bones, and the pool - // each bone does sit in — that is the whole fix: drop the bones that sit apart, or split the - // faces onto a material whose bones share a pool. + // No pool has them all — so one is made to. A pool is just "the bones this face group may name"; + // the missing ones are appended to the pool that lacks fewest (its own first, on a tie), and a + // pool that would pass the shipped ceiling is left alone in favour of a new one. string material = MafiaMaterials.GetMaterialName(newMats[slot].MaterialHash) ?? $"0x{newMats[slot].MaterialHash:X16}"; - string[] boneNames = BoneNamesOf(model); - IEnumerable told = set.Order().Select(b => + if (set.Count > MaxBonesPerPool) { - string name = b < boneNames.Length ? boneNames[b] : $"bone{b}"; - string pools = string.Join("/", Enumerable.Range(0, poolCount) - .Where(p => localOf[p].ContainsKey(b))); - return pools.Length == 0 ? $"{name} (in no pool)" : $"{name} (pool {pools})"; - }); - reason = $"material '{material}' is weighted to bones that no single remap pool of the model " - + "covers: " + string.Join(", ", told) + ". " - + "A pool CAN be made longer — no shipped pool exceeds " + MaxBonesPerPool + " and the " - + "totals run past a hundred — but the rig stores that same total in its own blend-id count " - + "and usage array, and what goes in the new entries has not been measured yet. Growing one " - + "half alone gives a car that looks right in the editor and lands the part somewhere else " - + "in game, so it is refused instead. For now: give those faces ONE vertex group, or move " - + "them onto a material whose bones already sit in one pool."; - return false; + reason = $"material '{material}' is weighted to {set.Count} different bones, and one face group " + + $"can name at most {MaxBonesPerPool} (no shipped model goes past it). Split those faces " + + "over two material slots — the same material may be used by both — so that each half " + + "stays under the limit."; + return null; + } + + int best = -1, bestMissing = int.MaxValue; + for (int step = 0; step <= poolCount; step++) + { + int p = step == 0 ? inherited : step - 1; + if (step > 0 && p == inherited) continue; + int missing = set.Count(b => !localOf[p].ContainsKey(b)); + if (pools[p].Count + missing > MaxBonesPerPool) continue; + if (missing < bestMissing) { best = p; bestMissing = missing; } + } + if (best < 0) + { + if (poolCount >= pools.Length) + { + string[] boneNames = BoneNamesOf(model); + reason = $"material '{material}' needs bones that no remap pool of the model has room " + + $"for (" + string.Join(", ", set.Order().Select(b => b < boneNames.Length + ? boneNames[b] : $"bone{b}")) + $"): every pool would pass {MaxBonesPerPool} " + + $"bones and all {pools.Length} pools are in use. Weight those faces to fewer bones."; + return null; + } + best = poolCount++; + } + foreach (byte bone in set.Order()) + { + if (localOf[best].ContainsKey(bone)) continue; + localOf[best][bone] = (byte)pools[best].Count; + pools[best].Add(bone); + } + groups[slot].AssignedPoolIndex = (byte)best; + grew = true; + } + + var sizes = new byte[oldSizes.Length]; + var newStart = new int[oldSizes.Length]; + var remap = new List(oldRemap.Length + 16); + for (int p = 0; p < pools.Length; p++) + { + newStart[p] = remap.Count; + sizes[p] = (byte)pools[p].Count; + remap.AddRange(pools[p]); + } + if (remap.Count > MaxBlendIds) + { + reason = $"this push needs a remap table of {remap.Count} entries, and the longest any shipped " + + $"model carries is {MaxBlendIds} — past that nothing is known about what the game does. " + + "Weight the new geometry to bones its material already uses."; + return null; } + var plan = new PoolPlan + { + Level = level, + Sizes = sizes, + Remap = [.. remap], + Groups = groups, + OldStart = oldStart, + OldSizes = oldSizes, + NewStart = newStart, + Grew = grew, + }; + // A vertex carries one set of ids, so every group drawing it must read them against the same pool. var poolOfVertex = new int[vertices.Length]; Array.Fill(poolOfVertex, -1); @@ -1642,27 +1839,34 @@ private static bool RemapBlendInfo( if (v < 0 || v >= vertices.Length) continue; if (poolOfVertex[v] >= 0 && poolOfVertex[v] != pool) { - reason = "a vertex is shared by two materials that read their bones from different " - + "pools — split the mesh along that material boundary and push again"; - return false; + plan.SharedAcrossPools = true; + return plan; } poolOfVertex[v] = pool; } } // Ids back to pool-local. A zero-weight slot keeps the donor's byte: it names nothing, and rewriting - // it would move bytes for no reason. + // it would move bytes for no reason — UNLESS that byte points past the end of this vertex's pool. It + // does when the vertex took its unused ids from a neighbour drawn through a bigger pool (a new corner + // copies the nearest old vertex before its own weights land), and although the weight is zero the + // id is still read: a skin with any id past the table does not resolve at all, here or on the next + // pull. Such a slot is pointed at the pool's first entry, which is always there. for (int v = 0; v < vertices.Length; v++) { int pool = poolOfVertex[v]; if (pool < 0) continue; // nothing draws this vertex for (int k = 0; k < 4; k++) { - if (vertices[v].BoneWeights[k] <= 0f) continue; + if (vertices[v].BoneWeights[k] <= 0f) + { + if (vertices[v].BoneIDs[k] >= sizes[pool]) vertices[v].BoneIDs[k] = 0; + continue; + } if (!localOf[pool].TryGetValue(globalOf[(v * 4) + k], out byte local)) { reason = "a vertex came back weighted to a bone its material's remap pool does not name"; - return false; + return null; } vertices[v].BoneIDs[k] = local; } @@ -1684,24 +1888,54 @@ private static bool RemapBlendInfo( for (int k = 0; k < 4; k++) { if (vertices[v].BoneWeights[k] <= 0f) continue; - int slot = poolStart[pool] + vertices[v].BoneIDs[k]; - if (slot < remap.Length && remap[slot] == globalOf[(v * 4) + k]) continue; + int slot = newStart[pool] + vertices[v].BoneIDs[k]; + if (vertices[v].BoneIDs[k] < sizes[pool] && slot < plan.Remap.Length + && plan.Remap[slot] == globalOf[(v * 4) + k]) + { + continue; + } reason = "the rebuilt skin does not read back — a vertex's bone id resolves to the wrong " + "bone through its own remap pool. Nothing was written; the mesh is as it was."; - return false; + return null; } } + return plan; + } + + /// + /// Writes a plan into the model: the edited level's pools and face groups, and — when the table moved — + /// everything else in the file that describes it. + /// + private static void CommitPools(FrameObjectModel model, PoolPlan plan) + { + FrameBlendInfo blend = model.GetBlendInfoObject(); + FrameBlendInfo.BoneIndexInfo[] lods = blend.BoneIndexInfos ?? []; + // The edited level only: the other levels keep their own vertex buffers and the pools that go with // them. - lods[level] = new FrameBlendInfo.BoneIndexInfo + lods[plan.Level] = new FrameBlendInfo.BoneIndexInfo { - BonesPerRemapPool = sizes, - BoneRemapIDs = remap, - SkinnedMaterialInfo = groups, + BonesPerRemapPool = plan.Sizes, + BoneRemapIDs = plan.Remap, + SkinnedMaterialInfo = plan.Groups, }; blend.BoneIndexInfos = lods; - return true; + if (!plan.Grew) return; + + // A split names its bone by POSITION in level 0's table. A pool that grew pushed every later pool + // along, so the positions are moved with it — same pool, same place in the pool, same bone. + if (plan.Level == 0) + { + foreach (FrameObjectModel.WeightedByMeshSplit split in model.BlendMeshSplits ?? []) + { + split.BlendIndex = (ushort)plan.Reindex(split.BlendIndex); + } + } + + // …and the skeleton's own account of the table: the count per level, the usage and reference arrays, + // the level masks. All derived from the pools, so derived again. + Frames.BlendPoolTables.Sync(model); } /// @@ -1931,6 +2165,13 @@ private static void DropEmptyPieces(FrameObjectModel model) /// private const int MaxBonesPerPool = 60; + /// + /// The most entries one level's remap table may hold — all its pools together. The longest shipped table + /// is 115 entries (--probe-remap-pools, 353 skinned models); the format would hold 255, since the + /// skeleton files table positions in a byte, but nothing is known about the game past what it ships. + /// + private const int MaxBlendIds = 115; + /// How many bones the model's rig has, or 0 when it cannot be read. private static int BoneCountOf(FrameObjectModel model) { diff --git a/src/Illusion.Assets/Bridge/BridgeMeshExporter.cs b/src/Illusion.Assets/Bridge/BridgeMeshExporter.cs index 6b563ac..99fdfb1 100644 --- a/src/Illusion.Assets/Bridge/BridgeMeshExporter.cs +++ b/src/Illusion.Assets/Bridge/BridgeMeshExporter.cs @@ -117,7 +117,12 @@ public static class BridgeMeshExporter return new MeshObjectPayload { - Id = MakeId(frame, document), + // The LEVEL is part of the identity. Both levels of a car are one frame, so sent together under + // the frame's id alone they were one object to the session: the push of the near level came back + // into the far one (whose pools do not hold its bones) and the other arrived as a brand-new mesh. + Id = decoded.Lod > 0 + ? MakeId(frame, document) + "|lod" + decoded.Lod.ToString(System.Globalization.CultureInfo.InvariantCulture) + : MakeId(frame, document), BoneIndices = boneIndices, BoneWeights = boneWeights, SkeletonId = skeletonId, diff --git a/src/Illusion.Assets/Bridge/SkinState.cs b/src/Illusion.Assets/Bridge/SkinState.cs new file mode 100644 index 0000000..6aa128c --- /dev/null +++ b/src/Illusion.Assets/Bridge/SkinState.cs @@ -0,0 +1,125 @@ +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Mathematics; + +namespace Illusion.Assets.Bridge; + +/// +/// Everything about a skinned model that a geometry push changes BESIDE its buffers: the remap pools and the +/// face groups that draw from them, the per-bone face ranges with their pieces and hit boxes, and the +/// skeleton's own account of the pools with its per-bone boxes. +/// +/// A push rewrites these, and they are not derived from the buffers alone — which pool a face group draws +/// from is a choice, and the vertex bytes only mean anything read through that choice. Undoing the buffers +/// while leaving them is how an undone push kept the old vertices and read them through the new pools: on a +/// car whose body had moved to another pool, one bone id in six came out as another bone. So a push carries +/// the state before and after, and puts back whichever belongs with the buffers it puts back. +/// +/// A snapshot is never handed to the model itself: applying it installs copies, because the next push edits +/// the split table in place and a redo must still find this one as it was taken. +/// +internal sealed class SkinState +{ + private readonly FrameBlendInfo.BoneIndexInfo[]? _levels; + private readonly FrameObjectModel.WeightedByMeshSplit[] _splits; + private readonly FrameObjectModel.HitBoxInfo[] _hitBoxes; + private readonly (int PhysSplitSize, int HitBoxSize, short NPhysSplits) _counters; + + private readonly bool _hasSkeleton; + private readonly int[] _levelCounts = []; + private readonly int _blendIds; + private readonly byte[] _levelMasks = []; + private readonly FrameSkeleton.MappingForBlendingInfo[] _mappings = []; + + private SkinState(FrameObjectModel model) + { + try { _levels = Copy(model.GetBlendInfoObject().BoneIndexInfos ?? []); } + catch (Exception) { _levels = null; } // no blend info to read: nothing of it to put back either + + _splits = Copy(model.BlendMeshSplits ?? []); + _hitBoxes = [.. (model.HitBoxes ?? []).Select(box => new FrameObjectModel.HitBoxInfo(box))]; + _counters = model.StoredSplitCounters; + + try + { + FrameSkeleton skeleton = model.GetSkeletonObject(); + _levelCounts = [.. skeleton.LodRemapIDCount ?? []]; + _blendIds = skeleton.NumBlendIDs; + _levelMasks = [.. skeleton.BoneLODUsage ?? []]; + _mappings = Copy(skeleton.MappingForBlendingInfos ?? []); + _hasSkeleton = true; + } + catch (Exception) + { + _hasSkeleton = false; + } + } + + /// The model's skin state as it stands. + public static SkinState Take(FrameObjectModel model) + { + ArgumentNullException.ThrowIfNull(model); + return new SkinState(model); + } + + /// Puts the model's skin state back to what it was when this was taken. + public void Apply(FrameObjectModel model) + { + ArgumentNullException.ThrowIfNull(model); + if (_levels != null) + { + try { model.GetBlendInfoObject().BoneIndexInfos = Copy(_levels); } + catch (Exception) { /* the blend info it was read from is gone — nothing to write into */ } + } + + model.BlendMeshSplits = Copy(_splits); + model.HitBoxes = [.. _hitBoxes.Select(box => new FrameObjectModel.HitBoxInfo(box))]; + // The counters as they were stored, not recomputed: this is "as it was", and a shipped model is + // entitled to whatever it shipped with. + model.StoredSplitCounters = _counters; + + if (!_hasSkeleton) return; + try + { + FrameSkeleton skeleton = model.GetSkeletonObject(); + skeleton.LodRemapIDCount = [.. _levelCounts]; + skeleton.NumBlendIDs = _blendIds; + skeleton.BoneLODUsage = [.. _levelMasks]; + skeleton.MappingForBlendingInfos = Copy(_mappings); + } + catch (Exception) + { + // as above + } + } + + private static FrameBlendInfo.BoneIndexInfo[] Copy(FrameBlendInfo.BoneIndexInfo[] levels) => + [.. levels.Select(level => new FrameBlendInfo.BoneIndexInfo + { + BonesPerRemapPool = [.. level.BonesPerRemapPool ?? []], + BoneRemapIDs = [.. level.BoneRemapIDs ?? []], + SkinnedMaterialInfo = [.. level.SkinnedMaterialInfo ?? []], + })]; + + // The types' own copy constructors copy one level down and share everything below it; the face-range + // rebuild replaces a piece's bursts in place, so the copy has to go all the way. + private static FrameObjectModel.WeightedByMeshSplit[] Copy(FrameObjectModel.WeightedByMeshSplit[] splits) => + [.. splits.Select(split => new FrameObjectModel.WeightedByMeshSplit(split) + { + Data = [.. (split.Data ?? []).Select(piece => new FrameObjectModel.BlendMeshSplitInfo(piece) + { + Data = [.. (piece.Data ?? []).Select(burst => new FrameObjectModel.MiniMaterialBurst(burst) + { + Data = [.. (burst.Data ?? []).Select(range => new FrameObjectModel.FacesBurst(range))], + })], + })], + })]; + + private static FrameSkeleton.MappingForBlendingInfo[] Copy(FrameSkeleton.MappingForBlendingInfo[] mappings) => + [.. mappings.Select(map => new FrameSkeleton.MappingForBlendingInfo + { + Bounds = [.. map.Bounds ?? Array.Empty()], + RefToUsageArray = [.. map.RefToUsageArray ?? []], + UsageArray = [.. map.UsageArray ?? []], + })]; +} diff --git a/src/Illusion.Assets/Frames/BlendPoolTables.cs b/src/Illusion.Assets/Frames/BlendPoolTables.cs new file mode 100644 index 0000000..17061e8 --- /dev/null +++ b/src/Illusion.Assets/Frames/BlendPoolTables.cs @@ -0,0 +1,135 @@ +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; + +namespace Illusion.Assets.Frames; + +/// +/// The SKELETON'S account of a skinned model's remap pools, rebuilt from the pools themselves. +/// +/// +/// The pools live in the blend info: per level, a flat table of bone ids cut into slices, and a vertex names +/// its bones by position in the slice its face group draws from. The skeleton describes the same table a +/// second time, and a pool cannot be made longer without that description following — the editor reads the +/// pools directly and would draw the part in place, while the game reads through the skeleton and would not. +/// Measured over all 353 skinned models of the install, 650 levels, with no exception +/// (--probe-remap-pools): +/// +/// +/// LodRemapIDCount[level] is the length of that level's table. +/// NumBlendIDs is the length of the longest table (always level 0's). +/// UsageArray is the table's own INDICES, sorted by the bone each entry names — a bone that +/// sits in two pools has its two entries side by side. +/// RefToUsageArray[bone] is where that bone's run ENDS in the usage array (a running total), so +/// a bone the level never names repeats its predecessor's value. +/// BoneLODUsage[bone] is a bit per level whose table names the bone. +/// +/// Every one of them is derived data, so it is derived again rather than patched. +/// +public static class BlendPoolTables +{ + /// + /// The usage and reference arrays one level's table implies. is the flat table + /// (already cut to the pools' total), the rig's bone count. + /// + public static (byte[] Usage, byte[] Refs) Describe(ReadOnlySpan remap, int boneCount) + { + // A counting sort by bone, stable in table order — exactly the shape of the two arrays. + var perBone = new int[Math.Max(boneCount, 1)]; + foreach (byte bone in remap) + { + if (bone < perBone.Length) perBone[bone]++; + } + + var refs = new byte[boneCount]; + var next = new int[perBone.Length]; + int running = 0; + for (int bone = 0; bone < boneCount; bone++) + { + next[bone] = running; + running += perBone[bone]; + refs[bone] = (byte)Math.Min(running, byte.MaxValue); + } + + var usage = new byte[remap.Length]; + for (int i = 0; i < remap.Length; i++) + { + byte bone = remap[i]; + if (bone >= boneCount) continue; // a table naming a bone the rig lacks has no place to file it + usage[next[bone]++] = (byte)i; + } + return (usage, refs); + } + + /// The level mask BoneLODUsage holds for each bone: bit n set when level n's table names it. + public static byte[] LevelMasks(FrameBlendInfo.BoneIndexInfo[] levels, int boneCount) + { + var masks = new byte[boneCount]; + for (int level = 0; level < levels.Length && level < 8; level++) + { + foreach (byte bone in Table(levels[level])) + { + if (bone < boneCount) masks[bone] |= (byte)(1 << level); + } + } + return masks; + } + + /// A level's flat table, cut to what its pools add up to. + public static ReadOnlySpan Table(FrameBlendInfo.BoneIndexInfo level) + { + byte[] remap = level.BoneRemapIDs ?? []; + int total = 0; + foreach (byte size in level.BonesPerRemapPool ?? []) total += size; + return remap.AsSpan(0, Math.Min(total, remap.Length)); + } + + /// + /// Rewrites the skeleton's counts, usage arrays, references and level masks from the model's pools as + /// they stand. A no-op in bytes for a model nobody has edited. False when the rig cannot be read or its + /// shape does not line up with the pools (a level the skeleton has no mapping for) — nothing is written. + /// + public static bool Sync(FrameObjectModel model) + { + ArgumentNullException.ThrowIfNull(model); + + FrameSkeleton skeleton; + FrameBlendInfo blend; + try + { + skeleton = model.GetSkeletonObject(); + blend = model.GetBlendInfoObject(); + } + catch (Exception) + { + return false; + } + + FrameBlendInfo.BoneIndexInfo[] levels = blend.BoneIndexInfos ?? []; + int bones = skeleton.BoneNames?.Length ?? 0; + FrameSkeleton.MappingForBlendingInfo[] maps = skeleton.MappingForBlendingInfos ?? []; + if (bones == 0 || levels.Length == 0 || maps.Length < levels.Length) return false; + + var counts = new int[levels.Length]; + int longest = 0; + for (int level = 0; level < levels.Length; level++) + { + ReadOnlySpan table = Table(levels[level]); + // The usage array holds table INDICES in a byte and the references running totals in one. + if (table.Length > byte.MaxValue) return false; + counts[level] = table.Length; + longest = Math.Max(longest, table.Length); + + (byte[] usage, byte[] refs) = Describe(table, bones); + FrameSkeleton.MappingForBlendingInfo map = maps[level]; + map.UsageArray = usage; + map.RefToUsageArray = refs; + maps[level] = map; + } + + skeleton.MappingForBlendingInfos = maps; + skeleton.LodRemapIDCount = counts; + skeleton.NumBlendIDs = longest; + skeleton.BoneLODUsage = LevelMasks(levels, bones); + return true; + } +} diff --git a/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectModel.cs b/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectModel.cs index 2c6b195..db8e90f 100644 --- a/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectModel.cs +++ b/src/Illusion.Formats/Frames/ObjectTypes/FrameObjectModel.cs @@ -129,6 +129,15 @@ public FrameSkeletonHierarchy SkeletonHierarchy /// . public int SplitBlockSizeStored => physSplitSize; + /// The three stored counters as they stand — read to remember a split table exactly as it was, + /// written to put it back that way (an undo). Anything that BUILDS a table calls + /// instead. + public (int PhysSplitSize, int HitBoxSize, short NPhysSplits) StoredSplitCounters + { + get { return SplitCounters; } + set { SplitCounters = value; } + } + public int ComputeSplitBlockSize() { if (blendMeshSplits is not { Length: > 0 }) return 0; diff --git a/src/Illusion/Bridge/BridgeSessionController.cs b/src/Illusion/Bridge/BridgeSessionController.cs index d18cde9..61e67e7 100644 --- a/src/Illusion/Bridge/BridgeSessionController.cs +++ b/src/Illusion/Bridge/BridgeSessionController.cs @@ -655,6 +655,13 @@ private void ApplyPush(PushMessage push) var sharedMeshNotes = new List(); var editedBuffers = new List<(string Name, ulong Hash, string Archive)>(); var geometry = new List(); + // Results already worked out in this push, per frame. Both levels of a mesh are one frame: they + // share its quantization lattice, its bounds and — on a model — its pools, and a result is + // computed against the frame as it stands. Two computed side by side against the SAME state each + // carry "the other level as it was", so applying the second put the first level's edit back + // where it came from. The second is therefore computed with the first in place. + var perFrame = new Dictionary>(); + var levelsMoved = new List<(string Name, FrameObjectSingleMesh Frame)>(); var transforms = new List(); var reshapes = new List(); var newHulls = new List(); @@ -755,9 +762,14 @@ private void ApplyPush(PushMessage push) continue; } - // Back into the level the row stands for — the same one it was exported from. + // Back into the level the row stands for — the same one it was exported from. What this + // push has already decided for the same frame goes in first and comes out again after: + // the batch below applies the results in this order, and undoes them in reverse. + FrameObjectSingleMesh? levelFrame = fn is FrameNodeAdapter { Frame: FrameObjectSingleMesh ofRow } ? ofRow : null; + List earlier = + levelFrame != null && perFrame.TryGetValue(levelFrame, out List? held) ? held : []; BridgeMeshApplier.ApplyResult? result = - BridgeMeshApplier.TryApply(fn, payload, out string? reason, node.Lod); + BridgeMeshApplier.TryApplyAfter(earlier, fn, payload, out string? reason, node.Lod); if (result == null) { ack.Skipped.Add(new PushSkip { Id = payload.Id, Reason = reason ?? "not applicable" }); @@ -785,6 +797,11 @@ private void ApplyPush(PushMessage push) if (!result.Unchanged) { geometry.Add(new GeometryEditController.GeometryItem(node, result)); + if (levelFrame != null) + { + if (!perFrame.TryGetValue(levelFrame, out List? sofar)) perFrame[levelFrame] = sofar = []; + sofar.Add(result); + } touchedTotal += result.TouchedVertices; // A frame references its mesh rather than owning it, and the shipped districts reuse @@ -815,11 +832,23 @@ private void ApplyPush(PushMessage push) // Object moved in Blender's Object Mode → re-localize against the CURRENT // parent and ride the same undoable batch. + // + // The FRAME is what stands somewhere, and its first level speaks for it. A coarser level + // is a second Blender object of the same frame: left where it was while the first one + // moved, it still carries the old matrix, and taken at its word it moved the frame back + // on the very next push — and forth again on the one after. if (!MatrixNear(payload.World, fn.WorldTransform)) { - transforms.Add(new GeometryEditController.TransformItem( - node, fn.LocalTransform, - TransformMath.ComputeLocalTransform(payload.World, fn.ParentWorldTransform))); + if (node.Lod == 0) + { + transforms.Add(new GeometryEditController.TransformItem( + node, fn.LocalTransform, + TransformMath.ComputeLocalTransform(payload.World, fn.ParentWorldTransform))); + } + else if (levelFrame != null) + { + levelsMoved.Add((payload.Name, levelFrame)); + } } ack.Applied.Add(payload.Id); @@ -867,6 +896,18 @@ private void ApplyPush(PushMessage push) }); continue; } + // A coarser LEVEL is not an object. Its row stands for the same frame as the first + // level's, so deleting "it" deleted the whole mesh, near level and all. + if (node.Lod > 0) + { + ack.Skipped.Add(new PushSkip + { + Id = id, + Reason = "a level of detail cannot be deleted on its own — the mesh keeps the level " + + "it had; delete the object itself (its first level) to remove it", + }); + continue; + } deleteNodes.Add(node); } var set = new HashSet(deleteNodes); @@ -1035,6 +1076,14 @@ private void ApplyPush(PushMessage push) }); var notes = new List(notesEarly); + // A coarser level moved by itself: said, since nothing happened. (Moved together with the first + // level it simply follows the frame, and there is nothing to say.) + foreach ((string name, FrameObjectSingleMesh frame) in levelsMoved) + { + if (transforms.Any(t => t.Node.Source is FrameNodeAdapter { Frame: FrameObjectSingleMesh moved } && ReferenceEquals(moved, frame))) continue; + notes.Add($"{name}: a coarser level was moved on its own — a mesh is placed by its first level, " + + "so the object stayed where it is. Move the LOD 0 object to move it."); + } if (skinNotSent.Count > 0) { notes.Add($"{string.Join(", ", skinNotSent.Take(3))}: no vertex weights came back — every " diff --git a/src/Illusion/Diagnostics/Probes/BridgeSkinProbes.cs b/src/Illusion/Diagnostics/Probes/BridgeSkinProbes.cs index f469a96..7a3c801 100644 --- a/src/Illusion/Diagnostics/Probes/BridgeSkinProbes.cs +++ b/src/Illusion/Diagnostics/Probes/BridgeSkinProbes.cs @@ -495,8 +495,10 @@ Formats.Frames.ObjectTypes.FrameObjectModel probed Check("a skinned body with a face removed is accepted", applied != null, why ?? ""); if (applied != null) { - // The REBUILT mesh's face count, not the model's index buffer: TryApply leaves the buffers - // untouched until the caller commits with ApplyNew, so the model still holds the old ones. + // Working a push out leaves the MODEL untouched as well as its buffers — the face ranges and + // pools ride in the result — so it is committed before the model is asked about them, and + // taken back at the end of this block for the cases that follow. + applied.ApplyNew(); int triangles = (applied.NewMesh?.Indices.Length ?? 0) / 3; // The whole point: no range may name a face the mesh no longer has. A stale one is what @@ -562,6 +564,7 @@ Formats.Frames.ObjectTypes.FrameObjectModel probed applied.NewMesh == null ? "no replacement mesh built" : $"ids {applied.NewMesh.BoneIndices?.Length ?? 0}, rig " + $"{applied.NewMesh.Skeleton?.Bones.Count ?? 0} bones"); + applied.RestoreOriginal(); } // ── The mirror case: geometry ADDED ── @@ -585,6 +588,7 @@ Formats.Frames.ObjectTypes.FrameObjectModel probed Check("a skinned body with a face ADDED is accepted", grown != null, growWhy ?? ""); if (grown != null) { + grown.ApplyNew(); // the model is asked about its face ranges below int triangles = (grown.NewMesh?.Indices.Length ?? 0) / 3; int verts = grown.NewMesh?.Positions.Length ?? 0; Check("the added geometry really arrived", @@ -645,7 +649,6 @@ Formats.Frames.ObjectTypes.FrameObjectModel probed $"{onWanted} of {checkedNew} added vertices on bone {wantBone} " + $"(nearest would have given {nearBone})"); - grown.ApplyNew(); Check("the grown model survives the writer and comes back", SurvivesRoundTrip(model3, verts, triangles, out string? tripWhy), tripWhy ?? ""); @@ -853,7 +856,7 @@ private static bool IsAdded(System.Numerics.Vector3 position, MeshObjectPayload /// is perfectly fine in memory and simply makes the car vanish in game. /// /// - private static bool SurvivesRoundTrip(FrameObjectModel model, int verts, int faces, out string? why) + internal static bool SurvivesRoundTrip(FrameObjectModel model, int verts, int faces, out string? why) { why = null; try @@ -910,7 +913,7 @@ private static bool SurvivesRoundTrip(FrameObjectModel model, int verts, int fac /// id read against the wrong pool binds geometry to whatever bone happens to sit at that offset. /// /// - private static (int Checked, int Wrong, int Groups) ResolvesThroughPools( + internal static (int Checked, int Wrong, int Groups) ResolvesThroughPools( FrameObjectModel model, BridgeMeshApplier.ApplyResult applied, byte[] pools, byte[] remap) { MeshData? mesh = applied.NewMesh; @@ -1000,7 +1003,7 @@ private static System.Numerics.Matrix4x4 Affine(System.Numerics.Matrix4x4 m) return m; } - private static IFrameNode? FindModelNode(SdsFrameNode node) + internal static IFrameNode? FindModelNode(SdsFrameNode node) { if (node.Source is IFrameNode f && IsModel(f)) return f; foreach (SdsFrameNode c in node.Children) diff --git a/src/Illusion/Diagnostics/Probes/RemapPoolProbes.cs b/src/Illusion/Diagnostics/Probes/RemapPoolProbes.cs new file mode 100644 index 0000000..3a97ae6 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/RemapPoolProbes.cs @@ -0,0 +1,960 @@ +using System.IO; +using System.Numerics; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Adapters; +using Illusion.Assets.Bridge; +using Illusion.Assets.Frames; +using Illusion.Assets.Sds; +using Illusion.Bridge.Payload; +using Illusion.Domain; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Hashing; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// +/// What a remap pool IS, measured rather than assumed — the census a pool REBUILD has to stand on. +/// +/// A skinned vertex names its bones through the pool of the face group that draws it. The pools live in the +/// blend info; the skeleton carries a second description of the same thing (a count per level, a usage array +/// per level, a per-bone reference into it, a per-bone level mask), and a rebuild that rewrites one half and +/// not the other gives a model the editor draws right and the game does not. Each hypothesis below is scored +/// over every skinned model in the install, so what the rebuild writes is what the shipped data does. +/// +/// Args: a focus archive name (default shubert_38). Output: %TEMP%\illusion_remap_pools.txt +/// +internal static class RemapPoolProbes +{ + internal static void RunRemapPoolProbe(string focus) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_remap_pools.txt"); + var sb = new StringBuilder(); + var dump = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + + var score = new Dictionary(); + var notes = new Dictionary>(); + void Score(string name, bool ok, string where) + { + (int Ok, int Of) s = score.GetValueOrDefault(name); + score[name] = (s.Ok + (ok ? 1 : 0), s.Of + 1); + if (ok) return; + if (!notes.TryGetValue(name, out List? list)) notes[name] = list = []; + if (list.Count < 6) list.Add(where); + } + var histogram = new Dictionary>(); + void Tally(string name, string value) + { + if (!histogram.TryGetValue(name, out Dictionary? h)) histogram[name] = h = []; + h[value] = h.GetValueOrDefault(value) + 1; + } + + int models = 0; + int longestTable = 0, widestPool = 0; + string longestTableAt = "", widestPoolAt = ""; + foreach (string kind in new[] { "cars", "traffic", "hchar", "player", "police_char" }) + { + string folder = Path.Combine(MafiaEnvironment.PcFolder, "sds", kind); + if (!Directory.Exists(folder)) continue; + foreach (FileInfo sds in new DirectoryInfo(folder).GetFiles("*.sds").OrderBy(f => f.Name)) + { + string extracted = MafiaEnvironment.ExtractedDir(sds); + if (!File.Exists(Path.Combine(extracted, "SDSContent.xml"))) continue; + FrameResource? fr; + try { fr = SdsMeshLoader.OpenScene(extracted).FrameResource; } + catch (Exception) { continue; } + if (fr?.FrameObjects == null) continue; + + // The whole file through the writer, before and after every model's skeleton is re-derived + // from its pools: an untouched model must come out byte for byte the file it was. + try + { + byte[] before = fr.WriteToStream(); + int synced = 0; + foreach (FrameObjectModel each in fr.FrameObjects.Values.OfType()) + if (BlendPoolTables.Sync(each)) synced++; + if (synced > 0) + { + Score("REBUILD: re-deriving the skeleton's tables leaves the frame file byte-identical", + before.AsSpan().SequenceEqual(fr.WriteToStream()), $"{kind}/{sds.Name}"); + } + } + catch (Exception ex) + { + Score("REBUILD: re-deriving the skeleton's tables leaves the frame file byte-identical", + false, $"{kind}/{sds.Name}: {ex.GetType().Name}"); + } + + foreach (FrameObjectModel model in fr.FrameObjects.Values.OfType()) + { + FrameSkeleton skeleton; + FrameBlendInfo blend; + try { skeleton = model.GetSkeletonObject(); blend = model.GetBlendInfoObject(); } + catch (Exception) { continue; } + HashName[] names = skeleton.BoneNames ?? []; + FrameBlendInfo.BoneIndexInfo[] lods = blend.BoneIndexInfos ?? []; + if (names.Length == 0 || lods.Length == 0) continue; + models++; + string who = $"{kind}/{sds.Name}:{model.Name}"; + bool isFocus = Path.GetFileNameWithoutExtension(sds.Name).Equals(focus, StringComparison.OrdinalIgnoreCase); + string Bone(int id) => id < names.Length ? names[id].ToString() ?? $"#{id}" : $"#{id}"; + + int[] perLod = skeleton.LodRemapIDCount ?? []; + FrameSkeleton.MappingForBlendingInfo[] maps = skeleton.MappingForBlendingInfos ?? []; + byte[] lodUsage = skeleton.BoneLODUsage ?? []; + Tally(kind + ": levels (pools / skeleton counts / mappings / geometry)", + $"{lods.Length}/{perLod.Length}/{maps.Length}/{model.Geometry?.LOD?.Length ?? -1}"); + Tally(kind + ": NumBones shape", string.Join("/", (skeleton.NumBones ?? []).Select(n => + n == names.Length ? "bones" : n.ToString(System.Globalization.CultureInfo.InvariantCulture)))); + Tally(kind + ": IDType", skeleton.IDType.ToString(System.Globalization.CultureInfo.InvariantCulture)); + Score("BoneLODUsage is one byte per bone", lodUsage.Length == names.Length, who); + + int total0 = (lods[0].BonesPerRemapPool ?? []).Sum(s => (int)s); + int totalMax = lods.Max(l => (l.BonesPerRemapPool ?? []).Sum(s => (int)s)); + int totalSum = lods.Sum(l => (l.BonesPerRemapPool ?? []).Sum(s => (int)s)); + Score("NumBlendIDs == level 0's pool total", skeleton.NumBlendIDs == total0, who); + Score("NumBlendIDs == the largest level's pool total", skeleton.NumBlendIDs == totalMax, who); + Score("NumBlendIDs == the pool totals of all levels added up", skeleton.NumBlendIDs == totalSum, who); + Score("NumBlendIDs == the bone count", skeleton.NumBlendIDs == names.Length, who); + + if (isFocus) + { + dump.AppendLine($"\n════ {who}: {names.Length} bones, NumBlendIDs {skeleton.NumBlendIDs}, " + + $"NumBones {string.Join("/", skeleton.NumBones ?? [])}, IDType {skeleton.IDType}, " + + $"LodRemapIDCount {string.Join("/", perLod)} ════"); + dump.AppendLine(" BoneLODUsage: " + string.Join(" ", lodUsage.Select((u, i) => $"{Bone(i)}={u}"))); + } + + var inLevel = new HashSet[lods.Length]; + var weightedInLevel = new HashSet[lods.Length]; + for (int lod = 0; lod < lods.Length; lod++) + { + FrameBlendInfo.BoneIndexInfo info = lods[lod]; + byte[] sizes = info.BonesPerRemapPool ?? []; + byte[] remap = info.BoneRemapIDs ?? []; + FrameBlendInfo.SkinnedMaterialInfo[] groups = info.SkinnedMaterialInfo ?? []; + int total = sizes.Sum(s => (int)s); + string at = $"{who} lod {lod}"; + inLevel[lod] = [.. remap.Take(total).Select(b => (int)b)]; + weightedInLevel[lod] = []; + + Tally(kind + ": pools in use per level", sizes.Count(s => s > 0).ToString(System.Globalization.CultureInfo.InvariantCulture)); + Tally(kind + ": BonesPerRemapPool array length", sizes.Length.ToString(System.Globalization.CultureInfo.InvariantCulture)); + if (total > longestTable) { longestTable = total; longestTableAt = at; } + if (sizes.Length > 0 && sizes.Max() > widestPool) { widestPool = sizes.Max(); widestPoolAt = at; } + Score("the remap table is exactly as long as the pools add up to", remap.Length == total, at); + Score("skeleton.LodRemapIDCount[level] == the pool total", + lod < perLod.Length && perLod[lod] == total, at); + + var poolStart = new int[sizes.Length]; + for (int p = 0, run = 0; p < sizes.Length; p++) { poolStart[p] = run; run += sizes[p]; } + bool usedPoolsFirst = true; + for (int p = 1; p < sizes.Length; p++) if (sizes[p] > 0 && sizes[p - 1] == 0) usedPoolsFirst = false; + Score("pools in use come first, with no empty pool between them", usedPoolsFirst, at); + + for (int p = 0; p < sizes.Length; p++) + { + if (sizes[p] == 0 || poolStart[p] + sizes[p] > remap.Length) continue; + byte[] slice = remap[poolStart[p]..(poolStart[p] + sizes[p])]; + Score("a pool names each bone once", slice.Distinct().Count() == slice.Length, $"{at} pool {p}"); + Score("a pool lists its bones in ascending order", + slice.Zip(slice.Skip(1)).All(pair => pair.First < pair.Second), $"{at} pool {p}"); + } + var appearances = remap.Take(total).GroupBy(b => b).ToDictionary(g => g.Key, g => g.Count()); + Tally(kind + ": most pools one bone sits in", + (appearances.Count == 0 ? 0 : appearances.Values.Max()).ToString(System.Globalization.CultureInfo.InvariantCulture)); + + // ── the skeleton's own description of this level ── + if (lod < maps.Length) + { + byte[] usage = maps[lod].UsageArray ?? []; + byte[] refs = maps[lod].RefToUsageArray ?? []; + Score("UsageArray is as long as the pool total", usage.Length == total, at); + Score("RefToUsageArray is one byte per bone", refs.Length == names.Length, at); + Score("Bounds is one box per bone", (maps[lod].Bounds?.Length ?? -1) == names.Length, at); + Score("UsageArray == BoneRemapIDs, entry for entry", + usage.Length == total && usage.AsSpan().SequenceEqual(remap.AsSpan(0, Math.Min(total, remap.Length))), at); + + (byte[] usageWant, byte[] refsWant) = BlendPoolTables.Describe( + remap.AsSpan(0, Math.Min(total, remap.Length)), names.Length); + Score("REBUILD: the usage array comes out of the pools exactly as shipped", + usage.AsSpan().SequenceEqual(usageWant), at); + Score("REBUILD: the reference array comes out of the pools exactly as shipped", + refs.AsSpan().SequenceEqual(refsWant), at); + + // Other readings of UsageArray, scored entry by entry. + for (int i = 0; i < Math.Min(usage.Length, total); i++) + { + Score(" UsageArray[i] == remap[i] (per entry)", usage[i] == remap[i], $"{at} entry {i}: usage {usage[i]}, remap {remap[i]}"); + Score(" UsageArray[i] is a bone id (< bone count)", usage[i] < names.Length, $"{at} entry {i}: {usage[i]}"); + } + // RefToUsageArray: per bone, an index into the level's table? + for (int b = 0; b < Math.Min(refs.Length, names.Length); b++) + { + bool present = inLevel[lod].Contains(b); + int first = Array.IndexOf(remap, (byte)b, 0, Math.Min(total, remap.Length)); + int last = Array.LastIndexOf(remap, (byte)b, Math.Min(total, remap.Length) - 1); + int firstUsage = Array.IndexOf(usage, (byte)b); + if (present) + { + Score(" a pooled bone's ref == its FIRST place in the remap table", refs[b] == first, $"{at} {Bone(b)}: ref {refs[b]}, first {first}, last {last}"); + Score(" a pooled bone's ref == its LAST place in the remap table", refs[b] == last, $"{at} {Bone(b)}: ref {refs[b]}, first {first}, last {last}"); + Score(" a pooled bone's ref == its first place in UsageArray", refs[b] == firstUsage, $"{at} {Bone(b)}: ref {refs[b]}, in usage {firstUsage}"); + Score(" usage[ref[bone]] == bone, for a pooled bone", refs[b] < usage.Length && usage[refs[b]] == b, $"{at} {Bone(b)}: ref {refs[b]}"); + } + else + { + Tally(kind + ": ref of a bone the level's pools do not name", refs[b].ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + } + if (isFocus) + { + dump.AppendLine($"\n lod {lod}: pools {string.Join("+", sizes.Where(s => s > 0))} = {total}"); + for (int p = 0; p < sizes.Length; p++) + { + if (sizes[p] == 0) continue; + dump.AppendLine($" pool {p}: " + string.Join(", ", + remap.Skip(poolStart[p]).Take(sizes[p]).Select(b => $"{b}:{Bone(b)}"))); + } + dump.AppendLine(" UsageArray: " + string.Join(" ", usage)); + dump.AppendLine(" BoneRemapIDs: " + string.Join(" ", remap)); + dump.AppendLine(" RefToUsageArray: " + string.Join(" ", refs.Select((r, b) => $"{b}>{r}"))); + } + } + + // ── what the geometry actually asks of the pools ── + DecodedMesh? decoded; + try { decoded = SdsMeshLoader.DecodeLod(model, lod); } + catch (Exception) { decoded = null; } + if (decoded?.BoneIndices is not { } ids || decoded.BoneWeights is not { } weights) continue; + MeshPart[] parts = SdsMeshLoader.BuildParts(model, decoded.Indices.Length, lod); + Score("one face group per material slot", groups.Length == parts.Length, $"{at}: {groups.Length} groups, {parts.Length} slots"); + if (groups.Length < parts.Length) continue; + + MaterialStruct[] mats = model.Material?.Materials is { } all && lod < all.Count ? all[lod] : []; + Score("no material is drawn by two slots of one level", + mats.Select(m => m.MaterialHash).Distinct().Count() == mats.Length, at); + + var usedOfPool = new Dictionary>(); + var poolOfVertex = new int[decoded.Positions.Length]; + Array.Fill(poolOfVertex, -1); + int sharedAcrossPools = 0; + for (int part = 0; part < parts.Length; part++) + { + int pool = groups[part].AssignedPoolIndex; + if (pool >= sizes.Length) continue; + if (!usedOfPool.TryGetValue(pool, out HashSet? used)) usedOfPool[pool] = used = []; + int most = 0; + var bonesOfPart = new HashSet(); + int end = Math.Min(parts[part].StartIndex + parts[part].IndexCount, decoded.Indices.Length); + for (int i = parts[part].StartIndex; i < end; i++) + { + int v = (int)decoded.Indices[i]; + if (v < 0 || (v * 4) + 3 >= ids.Length) continue; + if (poolOfVertex[v] >= 0 && poolOfVertex[v] != pool) sharedAcrossPools++; + poolOfVertex[v] = pool; + int here = 0; + for (int k = 0; k < 4; k++) + { + if (weights[(v * 4) + k] <= 0f) continue; + here++; + int slot = poolStart[pool] + ids[(v * 4) + k]; + if (slot < remap.Length) + { + used.Add(remap[slot]); + bonesOfPart.Add(remap[slot]); + weightedInLevel[lod].Add(remap[slot]); + } + Score(" a weighted id stays inside its pool", ids[(v * 4) + k] < sizes[pool], $"{at} slot {part}"); + } + most = Math.Max(most, here); + } + Score("a face group declares exactly as many weights as its heaviest vertex carries", + groups[part].NumWeightsPerVertex == most, $"{at} slot {part}: declares {groups[part].NumWeightsPerVertex}, carries {most}"); + Score("a face group declares at least as many weights as its heaviest vertex carries", + groups[part].NumWeightsPerVertex >= most, $"{at} slot {part}: declares {groups[part].NumWeightsPerVertex}, carries {most}"); + if (isFocus) + { + string material = part < mats.Length + ? MafiaMaterials.GetMaterialName(mats[part].MaterialHash) ?? $"0x{mats[part].MaterialHash:X16}" : "?"; + dump.AppendLine($" slot {part,2} {material,-28} pool {pool}, declares {groups[part].NumWeightsPerVertex}, " + + $"{parts[part].IndexCount / 3} faces, {bonesOfPart.Count} bones: " + + string.Join(", ", bonesOfPart.Order().Select(Bone))); + } + } + Score("no vertex is drawn through two different pools", sharedAcrossPools == 0, $"{at}: {sharedAcrossPools} index hits"); + for (int p = 0; p < sizes.Length; p++) + { + if (sizes[p] == 0) continue; + var named = new HashSet(remap.Skip(poolStart[p]).Take(sizes[p]).Select(b => (int)b)); + HashSet used = usedOfPool.GetValueOrDefault(p) ?? []; + Score("a pool names ONLY bones its face groups put weight on", named.IsSubsetOf(used), + $"{at} pool {p}: names {named.Count}, weighted {used.Count}, idle: " + + string.Join(", ", named.Except(used).Order().Take(8).Select(Bone))); + Score("every pool in use is drawn by some face group", usedOfPool.ContainsKey(p), $"{at} pool {p}"); + } + } + + // ── BoneLODUsage against what the levels name ── + for (int b = 0; b < Math.Min(lodUsage.Length, names.Length); b++) + { + int maskNamed = 0, maskWeighted = 0; + for (int lod = 0; lod < lods.Length; lod++) + { + if (inLevel[lod].Contains(b)) maskNamed |= 1 << lod; + if (weightedInLevel[lod]?.Contains(b) == true) maskWeighted |= 1 << lod; + } + int levelsNamed = System.Numerics.BitOperations.PopCount((uint)maskNamed); + Score(" BoneLODUsage == bit mask of the levels whose pools name the bone", lodUsage[b] == maskNamed, $"{who} {Bone(b)}: {lodUsage[b]} vs mask {maskNamed}"); + Score(" BoneLODUsage == how many levels' pools name the bone", lodUsage[b] == levelsNamed, $"{who} {Bone(b)}: {lodUsage[b]} vs {levelsNamed}"); + Score(" BoneLODUsage == bit mask of the levels that put weight on the bone", lodUsage[b] == maskWeighted, $"{who} {Bone(b)}: {lodUsage[b]} vs mask {maskWeighted}"); + Tally(kind + ": BoneLODUsage value / levels naming the bone", $"{lodUsage[b]} / mask {maskNamed}"); + } + + Score("REBUILD: the level masks come out of the pools exactly as shipped", + lodUsage.AsSpan().SequenceEqual(BlendPoolTables.LevelMasks(lods, names.Length)), who); + + // ── a split's BlendIndex against the level 0 table ── + byte[] remap0 = lods[0].BoneRemapIDs ?? []; + byte[] refs0 = maps.Length > 0 ? maps[0].RefToUsageArray ?? [] : []; + foreach (FrameObjectModel.WeightedByMeshSplit split in model.BlendMeshSplits ?? []) + { + int index = split.BlendIndex; + if (index >= remap0.Length) { Score(" a split's BlendIndex is inside level 0's remap table", false, who); continue; } + Score(" a split's BlendIndex is inside level 0's remap table", true, who); + int bone = remap0[index]; + Score(" a split's BlendIndex is its bone's FIRST place in the table", + Array.IndexOf(remap0, (byte)bone) == index, $"{who}: index {index}, bone {Bone(bone)}"); + Score(" a split's BlendIndex == RefToUsageArray[its bone]", + bone < refs0.Length && refs0[bone] == index, $"{who}: index {index}, bone {Bone(bone)}, ref {(bone < refs0.Length ? refs0[bone] : -1)}"); + } + var splitIndices = (model.BlendMeshSplits ?? []).Select(s => (int)s.BlendIndex).ToList(); + Score("one split per entry of level 0's remap table", + splitIndices.Count == total0 && splitIndices.Distinct().Count() == total0, $"{who}: {splitIndices.Count} splits, {total0} entries"); + Score("no two splits share a BlendIndex", splitIndices.Distinct().Count() == splitIndices.Count, who); + Score("splits are stored in ascending BlendIndex", splitIndices.Zip(splitIndices.Skip(1)).All(p => p.First < p.Second), who); + if (isFocus) + { + dump.AppendLine("\n splits (BlendIndex → bone, pieces): " + string.Join(", ", + (model.BlendMeshSplits ?? []).Select(s => $"{s.BlendIndex}→{(s.BlendIndex < remap0.Length ? Bone(remap0[s.BlendIndex]) : "?")}×{s.Data?.Length ?? 0}"))); + } + } + } + } + + sb.AppendLine($"REMAP POOL CENSUS: {models} skinned models"); + sb.AppendLine($" longest table: {longestTable} entries ({longestTableAt}); widest pool: {widestPool} ({widestPoolAt})\n"); + foreach ((string name, (int ok, int of)) in score.OrderBy(p => p.Key.TrimStart(), StringComparer.Ordinal)) + { + sb.AppendLine($"{(ok == of ? "ALWAYS" : ok == 0 ? "NEVER " : " ")} {ok,7} of {of,-7} {name}"); + if (ok != of && ok != 0 && notes.TryGetValue(name, out List? list)) + foreach (string n in list.Take(of - ok < 4 ? 6 : 3)) sb.AppendLine(" · " + n); + } + sb.AppendLine(); + foreach ((string name, Dictionary h) in histogram.OrderBy(p => p.Key, StringComparer.Ordinal)) + { + sb.AppendLine(name + ": " + string.Join(", ", h.OrderByDescending(p => p.Value).Take(14).Select(p => $"[{p.Key}] ×{p.Value}"))); + } + PushCases(sb, focus); + UndoAndLevels(sb, focus); + sb.Append(dump); + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + // ───────────────────────── the rebuild itself, on a car held in memory ───────────────────────── + + /// + /// Pushes that the shipped pools cannot answer for, each on a freshly loaded copy of the focus car: a + /// triangle added to one face group and weighted to bones that group's pool does not hold. Nothing is + /// written to the game — the model is edited in memory and put through the writer and back. + /// + private static void PushCases(StringBuilder sb, string focus) + { + sb.AppendLine("\n════ pushes the shipped pools cannot answer for ════"); + 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)}"); + } + + var car = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", focus + ".sds")); + if (!car.Exists) { sb.AppendLine(" no such car: " + focus); return; } + + // (label, how to pick the face group and the bones it is newly weighted to) + var cases = new (string Label, Func Pick)[] + { + ("the LAST pool takes one bone more", shape => + { + int last = shape.PoolCount - 1; + if (last < 1) return null; + int slot = shape.SlotOfPool(last); + byte? bone = shape.BoneOnlyIn(pool: 0, notIn: last); + return slot < 0 || bone == null ? null : (slot, new[] { bone.Value }, -1); + }), + ("an EARLIER pool takes one bone more, so every later pool moves along", shape => + { + if (shape.PoolCount < 2 || shape.Sizes[0] >= 60) return null; + int slot = shape.SlotOfPool(0); + byte? bone = shape.BoneOnlyIn(pool: 1, notIn: 0); + return slot < 0 || bone == null ? null : (slot, new[] { bone.Value }, -1); + }), + ("a face group needs more than its pool has room for, so it moves to the pool that does", shape => + { + if (shape.PoolCount < 2) return null; + int slot = shape.SlotOfPool(0); + int need = 60 - shape.Sizes[0] + 1; + byte[] bones = shape.BonesOnlyIn(pool: 1, notIn: 0, count: need); + return slot < 0 || bones.Length < need || need > 4 ? null : (slot, bones, -1); + }), + ("one new vertex drawn by two face groups that read different pools", shape => + { + if (shape.PoolCount < 2) return null; + int slot = shape.SlotOfPool(0), also = shape.SlotOfPool(1); + byte[] common = [.. shape.PoolSlice(0).Where(b => shape.PoolSlice(1).Contains(b)).Take(1)]; + return slot < 0 || also < 0 || common.Length == 0 ? null : (slot, common, also); + }), + ("a bone no pool of the level names at all", shape => + { + int slot = shape.SlotOfPool(shape.PoolCount - 1); + byte? bone = shape.BoneInNoPool(); + return slot < 0 || bone == null ? null : (slot, new[] { bone.Value }, -1); + }), + }; + + foreach ((string label, Func pick) in cases) + { + sb.AppendLine($"\n ── {label} ──"); + try + { + (List roots, _, ISceneDocument? document) = SdsMeshLoader.LoadHierarchy(car); + IFrameNode? node = null; + foreach (SdsFrameNode r in roots) node ??= BridgeSkinProbes.FindModelNode(r); + MeshObjectPayload? mesh = node == null || document == null + ? null : BridgeMeshExporter.TryExport(node, document, out _); + if (node == null || mesh == null) { Check("the car rides the bridge", false); continue; } + var model = (FrameObjectModel)((FrameNodeAdapter)node).Frame; + + Shape before = Shape.Of(model); + (int Slot, byte[] Bones, int Also)? picked = pick(before); + if (picked == null) { sb.AppendLine(" (this car's pools do not offer the case)"); continue; } + (int slot, byte[] bones, int also) = picked.Value; + string[] names = [.. (model.GetSkeletonObject().BoneNames ?? []).Select(n => n.ToString() ?? "?")]; + int poolWas = before.Groups[slot].AssignedPoolIndex; + sb.AppendLine($" slot {slot} (pool {poolWas}, {before.Sizes[poolWas]} bones) " + + "is given a triangle weighted to " + string.Join(" + ", bones.Select(b => names[b])) + + (also < 0 ? "" : $", and slot {also} (pool {before.Groups[also].AssignedPoolIndex}) a second one on the SAME three corners")); + + byte[] pristine = model.Resource.WriteToStream(); + byte[] lod1Before = [.. before.Lod1Remap]; + int[] splitBones = [.. (model.BlendMeshSplits ?? []).Select(s => (int)before.Remap[s.BlendIndex])]; + int wasVerts = mesh.Positions.Length; + MeshObjectPayload more = WithTriangle(mesh, slot, bones, also); + + BridgeMeshApplier.ApplyResult? applied = BridgeMeshApplier.TryApply(node, more, out string? why); + if (applied == null && why != null && why.Contains("different bones", StringComparison.Ordinal)) + { + // Not a failure of the rebuild: this group is at the ceiling already, and saying so is + // the right answer. + sb.AppendLine(" (refused, as it should be: " + why + ")"); + continue; + } + Check("the push is accepted", applied != null, why ?? ""); + if (applied == null) continue; + + // Working a push out no longer writes into the model — that is the caller's to do. + byte[] untouched = model.Resource.WriteToStream(); + Check("accepting a push leaves the model as it was until it is applied", + untouched.AsSpan().SequenceEqual(pristine)); + applied.ApplyNew(); + Shape after = Shape.Of(model); + int poolNow = after.Groups[slot].AssignedPoolIndex; + sb.AppendLine($" pools {string.Join("+", before.Sizes.Where(x => x > 0))} = {before.Remap.Length} → " + + $"{string.Join("+", after.Sizes.Where(x => x > 0))} = {after.Remap.Length}; " + + $"slot {slot} now draws from pool {poolNow}; " + + $"{wasVerts} welded vertices → {applied.NewMesh?.Positions.Length} in the buffer"); + + Check("every old entry kept its pool and its place in it", + Enumerable.Range(0, before.PoolCount).All(p => + after.PoolSlice(p).Take(before.Sizes[p]).SequenceEqual(before.PoolSlice(p)))); + Check("no pool passes 60 bones", after.Sizes.All(x => x <= 60), + string.Join("+", after.Sizes.Where(x => x > 0))); + Check("the new bones are in the pool the face group draws from", + bones.All(b => after.PoolSlice(poolNow).Contains(b))); + + FrameSkeleton skeleton = model.GetSkeletonObject(); + (byte[] usage, byte[] refs) = BlendPoolTables.Describe(after.Remap, names.Length); + Check("the skeleton's count for the level is the new table length", + skeleton.LodRemapIDCount[0] == after.Remap.Length && skeleton.NumBlendIDs == after.Remap.Length, + $"{skeleton.LodRemapIDCount[0]} / {skeleton.NumBlendIDs} vs {after.Remap.Length}"); + Check("its usage and reference arrays describe the new table", + skeleton.MappingForBlendingInfos[0].UsageArray.AsSpan().SequenceEqual(usage) + && skeleton.MappingForBlendingInfos[0].RefToUsageArray.AsSpan().SequenceEqual(refs)); + Check("its level masks follow", + skeleton.BoneLODUsage.AsSpan().SequenceEqual( + BlendPoolTables.LevelMasks(model.GetBlendInfoObject().BoneIndexInfos, names.Length))); + Check("the other level's pools were left alone", after.Lod1Remap.AsSpan().SequenceEqual(lod1Before)); + + // Splits may be DROPPED by the face-range rebuild (a piece left with no faces), never + // re-pointed: every split still there must name the bone it named before, in the same order. + int[] splitBonesAfter = [.. (model.BlendMeshSplits ?? []).Select(s => + s.BlendIndex < after.Remap.Length ? (int)after.Remap[s.BlendIndex] : -1)]; + Check("every split still names the bone it named before the table moved", + splitBonesAfter.All(b => b >= 0) && IsSubsequence(splitBonesAfter, splitBones), + $"{splitBonesAfter.Length} splits (was {splitBones.Length})"); + + (int Checked, int Wrong, int Groups) resolve = + BridgeSkinProbes.ResolvesThroughPools(model, applied, after.Sizes, after.Remap); + Check("every bone id in the new buffer resolves through its own group's pool", + resolve.Checked > 0 && resolve.Wrong == 0, $"{resolve.Checked - resolve.Wrong} of {resolve.Checked}"); + + int added = 0, right = 0; + if (applied.NewMesh?.BoneIndices is { } ids) + { + for (int v = 0; v < applied.NewMesh.Positions.Length; v++) + { + bool isNew = false; + for (int i = wasVerts; i < more.Positions.Length && !isNew; i++) + isNew = Vector3.Distance(more.Positions[i], applied.NewMesh.Positions[v]) < 1e-4f; + if (!isNew) continue; + added++; + if (bones.Contains(ids[v * 4])) right++; + } + } + Check("the added corners ride the bones they were weighted to", added > 0 && right == added, + $"{right} of {added}"); + if (also >= 0) + { + Check("the three shared corners came out once per pool, six vertices in all", added == 6, + $"{added} vertices"); + Check("…and the table did not have to grow for it", after.Remap.Length == before.Remap.Length, + $"{before.Remap.Length} → {after.Remap.Length}"); + } + + byte[]? readsBack = SdsMeshLoader.GlobalBoneIds(model); + Check("the skin reads back through the pools once the buffers are in place", readsBack != null, + readsBack == null ? SdsMeshLoader.DescribeBoneRemap(model) : ""); + int verts = applied.NewMesh?.Positions.Length ?? 0; + int faces = (applied.NewMesh?.Indices.Length ?? 0) / 3; + Check("the model survives the writer and comes back", + BridgeSkinProbes.SurvivesRoundTrip(model, verts, faces, out string? trip), trip ?? ""); + + // The file as the game would read it: written, read back, and its skeleton compared with + // what the pools it came back with imply. + var back = new FrameResource(); + using (var stream = new MemoryStream(model.Resource.WriteToStream())) back.ReadFromFile(stream); + FrameObjectModel? same = back.FrameObjects?.Values.OfType() + .FirstOrDefault(m => m.Name.ToString() == model.Name.ToString()); + bool agrees = false; + if (same != null) + { + Shape reread = Shape.Of(same); + FrameSkeleton sk = same.GetSkeletonObject(); + (byte[] u, byte[] r) = BlendPoolTables.Describe(reread.Remap, names.Length); + agrees = reread.Remap.AsSpan().SequenceEqual(after.Remap) + && sk.LodRemapIDCount[0] == reread.Remap.Length + && sk.MappingForBlendingInfos[0].UsageArray.AsSpan().SequenceEqual(u) + && sk.MappingForBlendingInfos[0].RefToUsageArray.AsSpan().SequenceEqual(r); + } + Check("…and the file that comes back still agrees with itself, pools against skeleton", agrees); + + // A SECOND pull and push on top: the grown model must be as editable as a stock one. + MeshObjectPayload? again = document == null ? null : BridgeMeshExporter.TryExport(node, document, out _); + Check("the grown model can be pulled into Blender again", + again != null && again.BoneIndices.Length > 0 && again.SkinWarning == null, again?.SkinWarning ?? ""); + if (again != null) + { + BridgeMeshApplier.ApplyResult? second = + BridgeMeshApplier.TryApply(node, WithTriangle(again, slot, bones, also), out string? secondWhy); + second?.ApplyNew(); + Shape third = Shape.Of(model); + Check("…and pushed again without the table growing a second time", + second != null && third.Remap.Length == after.Remap.Length, + secondWhy ?? $"{after.Remap.Length} → {third.Remap.Length}"); + + // …and both taken back, newest first: the model is the file it was before either. + second?.RestoreOriginal(); + applied.RestoreOriginal(); + Check("undoing both pushes gives back the model byte for byte", + model.Resource.WriteToStream().AsSpan().SequenceEqual(pristine)); + } + } + catch (Exception ex) + { + Check("no exception", false, ex.ToString()); + } + } + // ── the FAR level: its own table, its own count; level 0 and the splits must not notice ── + sb.AppendLine("\n ── the far level's pool takes a bone it never named ──"); + try + { + (List roots, _, ISceneDocument? document) = SdsMeshLoader.LoadHierarchy(car); + IFrameNode? node = null; + foreach (SdsFrameNode r in roots) node ??= BridgeSkinProbes.FindModelNode(r); + var model = node == null ? null : (FrameObjectModel)((FrameNodeAdapter)node).Frame; + FrameBlendInfo.BoneIndexInfo[] levels = model?.GetBlendInfoObject().BoneIndexInfos ?? []; + MeshObjectPayload? far = node == null || document == null || levels.Length < 2 + ? null : BridgeMeshExporter.TryExport(node, document, out _, lod: 1); + if (model == null || far == null) + { + sb.AppendLine(" (this car has no second level to push into)"); + } + else + { + byte[] table0 = [.. levels[0].BoneRemapIDs ?? []]; + byte[] table1 = [.. levels[1].BoneRemapIDs ?? []]; + ushort[] blendBefore = [.. (model.BlendMeshSplits ?? []).Select(s => s.BlendIndex)]; + int bonesInRig = model.GetSkeletonObject().BoneNames?.Length ?? 0; + int stranger = -1; + for (int b = 0; b < table0.Length && stranger < 0; b++) + if (!table1.Contains(table0[b])) stranger = table0[b]; + if (stranger < 0) + { + sb.AppendLine(" (the far level already names every bone the near one does)"); + } + else + { + BridgeMeshApplier.ApplyResult? applied = BridgeMeshApplier.TryApply( + node!, WithTriangle(far, far.FaceMaterials[0], [(byte)stranger]), out string? why, lod: 1); + byte[] pristine = model.Resource.WriteToStream(); + Check("the push into the far level is accepted", applied != null, why ?? ""); + if (applied != null) + { + applied.ApplyNew(); + levels = model.GetBlendInfoObject().BoneIndexInfos; + FrameSkeleton skeleton = model.GetSkeletonObject(); + byte[] grown = levels[1].BoneRemapIDs ?? []; + sb.AppendLine($" far table {table1.Length} → {grown.Length} entries; near table {table0.Length} → {(levels[0].BoneRemapIDs ?? []).Length}"); + Check("the far table grew by the one bone", grown.Length == table1.Length + 1 && grown.Contains((byte)stranger)); + Check("the near level's table was left alone", (levels[0].BoneRemapIDs ?? []).AsSpan().SequenceEqual(table0)); + Check("no split was re-pointed — they index the near table", + (model.BlendMeshSplits ?? []).Select(s => s.BlendIndex).SequenceEqual(blendBefore)); + Check("the skeleton counts each level by its own table, and the longest overall", + skeleton.LodRemapIDCount[1] == grown.Length && skeleton.LodRemapIDCount[0] == table0.Length + && skeleton.NumBlendIDs == Math.Max(grown.Length, table0.Length), + $"{string.Join("/", skeleton.LodRemapIDCount)}, blend ids {skeleton.NumBlendIDs}"); + (byte[] usage, byte[] refs) = BlendPoolTables.Describe(grown, bonesInRig); + Check("its far usage and reference arrays describe the grown table", + skeleton.MappingForBlendingInfos[1].UsageArray.AsSpan().SequenceEqual(usage) + && skeleton.MappingForBlendingInfos[1].RefToUsageArray.AsSpan().SequenceEqual(refs)); + Check("the bone is now marked as used by both levels", skeleton.BoneLODUsage[stranger] == 3, + skeleton.BoneLODUsage[stranger].ToString(System.Globalization.CultureInfo.InvariantCulture)); + Check("the far skin reads back through its pools", SdsMeshLoader.GlobalBoneIds(model, 1) != null); + Check("…and the near one still does", SdsMeshLoader.GlobalBoneIds(model, 0) != null); + var back = new FrameResource(); + using (var stream = new MemoryStream(model.Resource.WriteToStream())) back.ReadFromFile(stream); + FrameObjectModel? same = back.FrameObjects?.Values.OfType() + .FirstOrDefault(m => m.Name.ToString() == model.Name.ToString()); + Check("the file comes back through the writer with the grown far table", + same != null && (same.GetBlendInfoObject().BoneIndexInfos[1].BoneRemapIDs ?? []).AsSpan().SequenceEqual(grown) + && same.GetSkeletonObject().LodRemapIDCount[1] == grown.Length); + applied.RestoreOriginal(); + Check("undoing it gives back the model byte for byte", + model.Resource.WriteToStream().AsSpan().SequenceEqual(pristine)); + } + } + } + } + catch (Exception ex) + { + Check("no exception", false, ex.ToString()); + } + + // ── and the push that cannot be answered: refused, with the model exactly as it was ── + // A face group already naming many bones is weighted to bones of the other pool too. Its own pool is + // full, the other would pass 60 taking it in, and a third pool would carry the table past the longest + // one any shipped model has — so there is no arrangement left that the game is known to read. + sb.AppendLine("\n ── a push no arrangement of pools can answer is refused, and nothing is written ──"); + try + { + bool reached = false; + for (int slot = 0; slot < 16 && !reached; slot++) + { + (List roots, _, ISceneDocument? document) = SdsMeshLoader.LoadHierarchy(car); + IFrameNode? node = null; + foreach (SdsFrameNode r in roots) node ??= BridgeSkinProbes.FindModelNode(r); + MeshObjectPayload? mesh = node == null || document == null + ? null : BridgeMeshExporter.TryExport(node, document, out _); + if (node == null || mesh == null) break; + var model = (FrameObjectModel)((FrameNodeAdapter)node).Frame; + Shape shape = Shape.Of(model); + if (slot >= shape.Groups.Length) break; + if (shape.PoolCount < 2 || shape.Groups[slot].AssignedPoolIndex != 0) continue; + byte[] bones = shape.BonesOnlyIn(pool: 1, notIn: 0, count: 4); + if (bones.Length < 60 - shape.Sizes[0] + 1) break; + + byte[] before = model.Resource.WriteToStream(); + BridgeMeshApplier.ApplyResult? applied = + BridgeMeshApplier.TryApply(node, WithTriangle(mesh, slot, bones), out string? why); + if (applied != null) continue; // this group still fitted somewhere — try a busier one + reached = true; + sb.AppendLine($" slot {slot}: {why}"); + Check("the refusal names the limit it ran into", + why != null && (why.Contains("remap table", StringComparison.Ordinal) + || why.Contains("no remap pool", StringComparison.Ordinal) + || why.Contains("different bones", StringComparison.Ordinal))); + Check("the model is byte for byte what it was before the push", + before.AsSpan().SequenceEqual(model.Resource.WriteToStream())); + } + if (!reached) sb.AppendLine(" (every face group of this car still fits somewhere)"); + } + catch (Exception ex) + { + Check("no exception", false, ex.ToString()); + } + + sb.AppendLine($"\n PUSH CASES: {pass} passed, {fail} failed"); + } + + /// + /// Two things a self-review of the pool rebuild said go wrong, each run for real: what is left in the + /// model when a push whose pools grew is UNDONE, and what two levels of one frame pushed together do to + /// each other. + /// + private static void UndoAndLevels(StringBuilder sb, string focus) + { + sb.AppendLine("\n════ undo of a push that changed the pools; two levels in one push ════"); + 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)}"); + } + + var car = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", focus + ".sds")); + if (!car.Exists) return; + try + { + foreach (bool movesPool in new[] { false, true }) + { + (List roots, _, ISceneDocument? document) = SdsMeshLoader.LoadHierarchy(car); + IFrameNode? node = null; + foreach (SdsFrameNode r in roots) node ??= BridgeSkinProbes.FindModelNode(r); + MeshObjectPayload? mesh = node == null || document == null ? null : BridgeMeshExporter.TryExport(node, document, out _); + if (node == null || mesh == null) continue; + var model = (FrameObjectModel)((FrameNodeAdapter)node).Frame; + Shape shape = Shape.Of(model); + if (shape.PoolCount < 2) { sb.AppendLine(" (one pool only — nothing to move between)"); break; } + + int slot; + byte[] bones; + if (movesPool) + { + int need = 60 - shape.Sizes[0] + 1; + slot = shape.SlotOfPool(0); + bones = shape.BonesOnlyIn(pool: 1, notIn: 0, count: need); + if (slot < 0 || bones.Length < need || need > 4) { sb.AppendLine(" (no face group has to change pool on this car)"); continue; } + } + else + { + slot = shape.SlotOfPool(shape.PoolCount - 1); + byte? bone = shape.BoneOnlyIn(pool: 0, notIn: shape.PoolCount - 1); + if (slot < 0 || bone == null) continue; + bones = [bone.Value]; + } + + byte[]? before = SdsMeshLoader.GlobalBoneIds(model); + byte[] fileBefore = model.Resource.WriteToStream(); + BridgeMeshApplier.ApplyResult? applied = BridgeMeshApplier.TryApply(node, WithTriangle(mesh, slot, bones), out string? why); + if (applied == null && why != null && why.Contains("different bones", StringComparison.Ordinal)) + { + sb.AppendLine(" (this face group is at the ceiling already and is refused, as it should be)"); + continue; + } + if (applied == null || before == null) { Check("the push is accepted", false, why ?? "no skin"); continue; } + applied.ApplyNew(); + applied.RestoreOriginal(); + Check(movesPool + ? "UNDO after a face group moved to another pool: the model is byte for byte the file it was" + : "UNDO after a pool merely grew: the model is byte for byte the file it was", + model.Resource.WriteToStream().AsSpan().SequenceEqual(fileBefore)); + byte[]? after = SdsMeshLoader.GlobalBoneIds(model); + int wrong = after == null ? -1 : Enumerable.Range(0, Math.Min(before.Length, after.Length)).Count(i => before[i] != after[i]) + + Math.Abs(before.Length - after.Length); + Check(movesPool + ? "UNDO after a face group moved to another pool: every vertex still names the bone it named" + : "UNDO after a pool merely grew: every vertex still names the bone it named", + after != null && wrong == 0, + after == null ? "the skin no longer resolves: " + SdsMeshLoader.DescribeBoneRemap(model)[..Math.Min(200, SdsMeshLoader.DescribeBoneRemap(model).Length)] + : $"{wrong} of {before.Length} bone ids differ"); + } + + // Two levels, one push: each result is computed against the frame as it stood, then both are applied. + { + (List roots, _, ISceneDocument? document) = SdsMeshLoader.LoadHierarchy(car); + IFrameNode? node = null; + foreach (SdsFrameNode r in roots) node ??= BridgeSkinProbes.FindModelNode(r); + MeshObjectPayload? near = node == null || document == null ? null : BridgeMeshExporter.TryExport(node, document, out _, lod: 0); + MeshObjectPayload? far = node == null || document == null ? null : BridgeMeshExporter.TryExport(node, document, out _, lod: 1); + if (node == null || near == null || far == null || near.Id == far.Id) + { + sb.AppendLine(" (this car has no second level)"); + } + else + { + var model = (FrameObjectModel)((FrameNodeAdapter)node).Frame; + // One vertex of each level lifted five metres — outside the box both levels are packed against. + Vector3 liftedNear = near.Positions[0] + new Vector3(0, 0, 5f); + Vector3 liftedFar = far.Positions[0] + new Vector3(0, 0, 6f); + near.Positions[0] = liftedNear; + far.Positions[0] = liftedFar; + // As the session does it: the second level is worked out with the first one in place. + byte[] fileBefore = model.Resource.WriteToStream(); + BridgeMeshApplier.ApplyResult? r0 = BridgeMeshApplier.TryApply(node, near, out string? why0, lod: 0); + BridgeMeshApplier.ApplyResult? r1 = r0 == null ? null + : BridgeMeshApplier.TryApplyAfter([r0], node, far, out why0, lod: 1); + Check("both levels are accepted", r0 != null && r1 != null, why0 ?? ""); + if (r0 != null && r1 != null) + { + Check("working both out leaves the model untouched", + model.Resource.WriteToStream().AsSpan().SequenceEqual(fileBefore)); + r0.ApplyNew(); + r1.ApplyNew(); + bool Has(int lod, Vector3 want) => + SdsMeshLoader.DecodeLod(model, lod)?.Positions.Any(p => Vector3.Distance(p, want) < 0.05f) == true; + Check("TWO LEVELS in one push: the near level still holds its own edit", Has(0, liftedNear), + $"requantized near {r0.Requantized}, far {r1.Requantized}"); + Check("TWO LEVELS in one push: the far level holds its edit", Has(1, liftedFar)); + Check("…and both skins still read through their pools", + SdsMeshLoader.GlobalBoneIds(model, 0) != null && SdsMeshLoader.GlobalBoneIds(model, 1) != null); + r1.RestoreOriginal(); + r0.RestoreOriginal(); + Check("undone in reverse, the model is byte for byte the file it was", + model.Resource.WriteToStream().AsSpan().SequenceEqual(fileBefore)); + } + } + } + } + catch (Exception ex) + { + Check("no exception", false, ex.ToString()); + } + sb.AppendLine($"\n UNDO AND LEVELS: {pass} passed, {fail} failed"); + } + + private static bool IsSubsequence(int[] part, int[] whole) + { + int at = 0; + foreach (int item in whole) + { + if (at < part.Length && part[at] == item) at++; + } + return at == part.Length; + } + + /// Level 0's pools of a model, as the cases above ask about them. + private sealed class Shape + { + internal byte[] Sizes = []; + internal byte[] Remap = []; + internal byte[] Lod1Remap = []; + internal FrameBlendInfo.SkinnedMaterialInfo[] Groups = []; + internal int Bones; + + internal int PoolCount => Sizes.Count(s => s > 0); + + internal static Shape Of(FrameObjectModel model) + { + FrameBlendInfo.BoneIndexInfo[] lods = model.GetBlendInfoObject().BoneIndexInfos; + return new Shape + { + Sizes = lods[0].BonesPerRemapPool ?? [], + Remap = lods[0].BoneRemapIDs ?? [], + Lod1Remap = lods.Length > 1 ? lods[1].BoneRemapIDs ?? [] : [], + Groups = lods[0].SkinnedMaterialInfo ?? [], + Bones = model.GetSkeletonObject().BoneNames?.Length ?? 0, + }; + } + + internal byte[] PoolSlice(int pool) + { + int start = 0; + for (int p = 0; p < pool; p++) start += Sizes[p]; + return Remap[start..(start + Sizes[pool])]; + } + + internal int SlotOfPool(int pool) => Array.FindIndex(Groups, g => g.AssignedPoolIndex == pool); + + internal byte[] BonesOnlyIn(int pool, int notIn, int count) => + [.. PoolSlice(pool).Where(b => !PoolSlice(notIn).Contains(b)).Take(count)]; + + internal byte? BoneOnlyIn(int pool, int notIn) => + BonesOnlyIn(pool, notIn, 1) is { Length: > 0 } found ? found[0] : null; + + internal byte? BoneInNoPool() + { + for (int b = Bones - 1; b >= 0; b--) + { + if (!Remap.Contains((byte)b)) return (byte)b; + } + return null; + } + } + + /// The same mesh with one triangle more: three new corners beside the first face of + /// , drawn with that slot's material and weighted evenly to . + /// With , a second triangle on the SAME three corners is drawn with + /// that other slot's material — one welded vertex, one normal, one UV, two face groups. + private static MeshObjectPayload WithTriangle(MeshObjectPayload mesh, int slot, byte[] bones, int alsoSlot = -1) + { + int face = Array.IndexOf(mesh.FaceMaterials, (ushort)slot); + if (face < 0) face = 0; + int at = mesh.Positions.Length; + var offset = new Vector3(0.013f, 0.011f, 0.012f); + + byte[] ids = new byte[(at + 3) * 4]; + float[] weights = new float[(at + 3) * 4]; + Array.Copy(mesh.BoneIndices, ids, Math.Min(mesh.BoneIndices.Length, at * 4)); + Array.Copy(mesh.BoneWeights, weights, Math.Min(mesh.BoneWeights.Length, at * 4)); + int influences = Math.Min(bones.Length, 4); + for (int i = 0; i < 3; i++) + { + for (int k = 0; k < influences; k++) + { + ids[((at + i) * 4) + k] = bones[k]; + weights[((at + i) * 4) + k] = 1f / influences; + } + } + + int l = face * 3; + T[] Twice(T[] corners) => alsoSlot < 0 ? corners : [.. corners, .. corners]; + return new MeshObjectPayload + { + BoneIndices = ids, + BoneWeights = weights, + Id = mesh.Id, + Name = mesh.Name, + World = mesh.World, + Local = mesh.Local, + Positions = [.. mesh.Positions, + mesh.Positions[mesh.LoopVertexIndices[l]] + offset, + mesh.Positions[mesh.LoopVertexIndices[l + 1]] + offset, + mesh.Positions[mesh.LoopVertexIndices[l + 2]] + offset], + LoopVertexIndices = [.. mesh.LoopVertexIndices, .. Twice(new[] { (uint)at, (uint)(at + 1), (uint)(at + 2) })], + LoopNormals = [.. mesh.LoopNormals, .. Twice(new[] { mesh.LoopNormals[l], mesh.LoopNormals[l + 1], mesh.LoopNormals[l + 2] })], + LoopUvs = [.. mesh.LoopUvs, .. Twice(new[] { mesh.LoopUvs[l], mesh.LoopUvs[l + 1], mesh.LoopUvs[l + 2] })], + LoopOrigIndex = [.. mesh.LoopOrigIndex, .. Twice(new[] { -1, -1, -1 })], + FaceMaterials = alsoSlot < 0 + ? [.. mesh.FaceMaterials, (ushort)slot] + : [.. mesh.FaceMaterials, (ushort)slot, (ushort)alsoSlot], + Materials = mesh.Materials, + VertexDeclaration = mesh.VertexDeclaration, + DecompressionOffset = mesh.DecompressionOffset, + DecompressionFactor = mesh.DecompressionFactor, + }; + } +} From 23fba4d6213e706bb72b88d0c38dfcaa3894068e Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:06:30 +0300 Subject: [PATCH 070/102] fix(bridge): a person's mesh is not turned twice in Blender A person's model frame carries a quarter turn and the mesh already held the model's world when it was parented to the armature, so the turn was applied twice: the body arrived lying down beside its rig. The mesh's offset from the armature is now armature.world^-1 * world. --probe-person-rig; the importer functions were run in a background Blender, not in an editor session. --- .../addons/illusion_bridge/importer.py | 9 + .../Diagnostics/Probes/PersonRigProbes.cs | 163 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/Illusion/Diagnostics/Probes/PersonRigProbes.cs diff --git a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/importer.py b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/importer.py index 6cfca8e..8a478f8 100644 --- a/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/importer.py +++ b/src/Illusion/BlenderAddon/scripts/addons/illusion_bridge/importer.py @@ -262,7 +262,16 @@ def _bind_skin(obj, desc, header, blocks, armatures, warnings): group = groups[b] = obj.vertex_groups.new(name=bone_names[b]) group.add((v,), w, 'REPLACE') + # Parenting keeps the mesh's own matrix as its offset FROM the armature — and that matrix is already the + # model's world, the same one the armature stands at. Left alone the mesh is carried there twice. On a + # car nobody sees it (a car's model sits at the identity); a person's model is turned a quarter turn — + # its geometry is stored along the spine — so the body arrived lying down beside a rig that stood, and + # a push from that scene read as "the object was moved". The offset is what is left of the mesh's world + # once the armature's is taken out: nothing, for a mesh on its own rig. + world = obj.matrix_world.copy() obj.parent = armature + obj.matrix_parent_inverse = Matrix.Identity(4) + obj.matrix_basis = armature.matrix_world.inverted_safe() @ world modifier = obj.modifiers.new(name="Illusion Rig", type='ARMATURE') modifier.object = armature modifier.use_vertex_groups = True diff --git a/src/Illusion/Diagnostics/Probes/PersonRigProbes.cs b/src/Illusion/Diagnostics/Probes/PersonRigProbes.cs new file mode 100644 index 0000000..e503b7c --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/PersonRigProbes.cs @@ -0,0 +1,163 @@ +using System.IO; +using System.Numerics; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Frames; +using Illusion.Formats.Frames.ObjectTypes; +using Illusion.Formats.Frames.Resources; +using Illusion.Formats.Hashing; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// +/// Where a person's mesh and rig really stand. A car is authored in the pose its bones rest in, so the +/// geometry as stored and the geometry as drawn are the same thing; a person need not be — and anything that +/// hands the stored geometry to another program (the Blender bridge) then shows it lying down beside a rig +/// that stands. This prints both, bone by bone. +/// Args: an archive under pc\sds (default traffic/cirand.sds). Output: %TEMP%\illusion_person_rig.txt +/// +internal static class PersonRigProbes +{ + internal static void RunPersonRigProbe(string archive) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_person_rig.txt"); + var sb = new StringBuilder(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + var sds = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", archive.Replace('/', Path.DirectorySeparatorChar))); + string extracted = SdsMeshLoader.EnsureExtracted(sds); + FrameResource? fr = SdsMeshLoader.OpenScene(extracted).FrameResource; + if (fr?.FrameObjects == null) { sb.AppendLine("no frame resource"); return; } + + foreach (FrameObjectModel model in fr.FrameObjects.Values.OfType()) + { + sb.AppendLine($"MODEL {model.Name}"); + sb.AppendLine(" local " + Describe(model.LocalTransform)); + sb.AppendLine(" world " + Describe(model.WorldTransform)); + + FrameSkeleton skeleton = model.GetSkeletonObject(); + HashName[] names = skeleton.BoneNames ?? []; + byte[] parents = model.GetSkeletonHierarchyObject().ParentIndices ?? []; + Matrix4x4[] rest = [.. (model.RestTransform ?? []).Select(Affine)]; + Matrix4x4[] worlds = [.. (skeleton.WorldTransforms ?? []).Select(Affine)]; + Matrix4x4[] joints = [.. (skeleton.JointTransforms ?? []).Select(Affine)]; + sb.AppendLine($" bones {names.Length}, rest {rest.Length}, skeleton.World {worlds.Length}, skeleton.Joint {joints.Length}"); + + // Accumulated down the hierarchy — the reading in which a rest transform is local to its parent. + var chained = new Matrix4x4[rest.Length]; + for (int i = 0; i < rest.Length; i++) + { + int p = i < parents.Length ? parents[i] : i; + chained[i] = p == i || p >= i ? rest[i] : rest[i] * chained[p]; + } + + // A car's wheels stand on these: where the axles and the brake drums are says how big a wheel is. + for (int i = 0; i < rest.Length && i < names.Length; i++) + { + string bone = names[i].ToString() ?? ""; + if (bone.StartsWith("axle", StringComparison.OrdinalIgnoreCase) + || bone.StartsWith("brake", StringComparison.OrdinalIgnoreCase) + || bone.Contains("wheel", StringComparison.OrdinalIgnoreCase)) + { + sb.AppendLine($" wheel bone {bone,-16} {V(rest[i].Translation)}"); + } + } + + int show = Math.Min(rest.Length, 14); + sb.AppendLine("\n bone parent rest (as stored) chained down the hierarchy"); + for (int i = 0; i < show; i++) + { + int p = i < parents.Length ? parents[i] : -1; + sb.AppendLine($" {i,2} {names[i],-18} {p,3} {V(rest[i].Translation)} {V(chained[i].Translation)}"); + } + + // The palette each reading gives: bind (the skeleton's own table) times the pose. + sb.AppendLine("\n palette = skeleton.World[i] * pose[i] (identity = stored geometry is drawn as stored)"); + for (int i = 0; i < show && i < worlds.Length; i++) + { + sb.AppendLine($" {i,2} {names[i],-18} rest: {Describe(worlds[i] * rest[i])}"); + sb.AppendLine($" {"",-18} chain: {Describe(worlds[i] * chained[i])}"); + } + + // inverse(skeleton.World[i]) is the pose the geometry was skinned in: where does it put bones? + sb.AppendLine("\n bind pose = inverse(skeleton.World[i])"); + for (int i = 0; i < show && i < worlds.Length; i++) + { + sb.AppendLine(Matrix4x4.Invert(worlds[i], out Matrix4x4 bind) + ? $" {i,2} {names[i],-18} {Describe(bind)}" + : $" {i,2} {names[i],-18} does not invert"); + } + + DecodedMesh? decoded = SdsMeshLoader.DecodeLod(model, 0); + if (decoded == null) { sb.AppendLine(" no LOD0"); continue; } + Bounds(decoded.Positions, out Vector3 min, out Vector3 max); + sb.AppendLine($"\n stored geometry {V(min)} .. {V(max)}"); + + byte[]? ids = SdsMeshLoader.ResolveBoneRemap( + model, SdsMeshLoader.BuildParts(model, decoded.Indices.Length, decoded.Lod), decoded); + if (ids == null || decoded.BoneWeights is not { } weights) { sb.AppendLine(" skin does not resolve"); continue; } + + foreach ((string label, Matrix4x4[] pose) in new[] { ("rest", rest), ("chain", chained) }) + { + var skinned = new Vector3[decoded.Positions.Length]; + for (int v = 0; v < skinned.Length; v++) + { + Vector3 sum = Vector3.Zero; + float total = 0; + for (int k = 0; k < 4; k++) + { + float w = weights[(v * 4) + k]; + int b = ids[(v * 4) + k]; + if (w <= 0 || b >= pose.Length || b >= worlds.Length) continue; + sum += Vector3.Transform(decoded.Positions[v], worlds[b] * pose[b]) * w; + total += w; + } + skinned[v] = total > 0 ? sum / total : decoded.Positions[v]; + } + Bounds(skinned, out Vector3 smin, out Vector3 smax); + sb.AppendLine($" skinned, pose={label,-6} {V(smin)} .. {V(smax)}"); + Bounds([.. skinned.Select(p => Vector3.Transform(p, Affine(model.WorldTransform)))], out smin, out smax); + sb.AppendLine($" ... in the world {V(smin)} .. {V(smax)}"); + } + sb.AppendLine(); + } + } + catch (Exception ex) + { + sb.AppendLine("unexpected exception — " + ex); + } + finally + { + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static void Bounds(Vector3[] points, out Vector3 min, out Vector3 max) + { + min = new Vector3(float.MaxValue); + max = new Vector3(float.MinValue); + foreach (Vector3 p in points) + { + min = Vector3.Min(min, p); + max = Vector3.Max(max, p); + } + } + + private static string V(Vector3 v) => FormattableString.Invariant($"({v.X,7:F3} {v.Y,7:F3} {v.Z,7:F3})"); + + // Translation plus where the three axes point — enough to read a quarter turn off the page. + private static string Describe(Matrix4x4 m) => FormattableString.Invariant( + $"t {V(m.Translation)} x→({m.M11,5:F2} {m.M12,5:F2} {m.M13,5:F2}) y→({m.M21,5:F2} {m.M22,5:F2} {m.M23,5:F2}) z→({m.M31,5:F2} {m.M32,5:F2} {m.M33,5:F2})"); + + private static Matrix4x4 Affine(Matrix4x4 m) + { + m.M14 = 0; + m.M24 = 0; + m.M34 = 0; + m.M44 = 1; + return m; + } +} From 8026257836168364b2aecb5a832101948fea1d56 Mon Sep 17 00:00:00 2001 From: raighen <61993288+raighen@users.noreply.github.com> Date: Tue, 6 Oct 2026 03:06:30 +0300 Subject: [PATCH 071/102] feat(browser): pictures of archives in the content browser People, wardrobe pieces and cars are drawn from their archive on a headless device and kept on disk by archive state. Only tiles on screen are read, off the UI thread; a car is shown on its wheels with helper meshes, glass and snow parts left out. --probe-archive-thumbs 9/9. --- .../Textures/TextureLibrary.cs | 16 + .../Probes/ArchiveThumbnailProbes.cs | 267 ++++++++ .../Viewport/ArchiveThumbnailRenderer.cs | 641 ++++++++++++++++++ src/Illusion/Views/ArchiveThumb.cs | 246 +++++++ src/Illusion/Views/ContentBrowser.xaml | 27 +- 5 files changed, 1192 insertions(+), 5 deletions(-) create mode 100644 src/Illusion/Diagnostics/Probes/ArchiveThumbnailProbes.cs create mode 100644 src/Illusion/Viewport/ArchiveThumbnailRenderer.cs create mode 100644 src/Illusion/Views/ArchiveThumb.cs diff --git a/src/Illusion.Rendering/Textures/TextureLibrary.cs b/src/Illusion.Rendering/Textures/TextureLibrary.cs index c9cc084..0264807 100644 --- a/src/Illusion.Rendering/Textures/TextureLibrary.cs +++ b/src/Illusion.Rendering/Textures/TextureLibrary.cs @@ -84,6 +84,22 @@ public void AddFolder(string folder) } } + /// + /// Forgets every registered folder — for a renderer that shows one thing after another, each with + /// folders of its own: a texture is taken from the FIRST folder that has its name, so folders left from + /// what was shown before would lend their textures to what is shown next. Textures already acquired stay + /// as they are until their leases are returned. + /// + public void ClearFolders() + { + lock (_sync) + { + if (_folders.Count == 0) return; + _folders.Clear(); + _foldersVersion++; + } + } + /// /// Forgets a cached texture whose FILE was rewritten, so the next reads it again. /// Meshes that already hold the old view keep drawing with it until they rebind or are disposed — the diff --git a/src/Illusion/Diagnostics/Probes/ArchiveThumbnailProbes.cs b/src/Illusion/Diagnostics/Probes/ArchiveThumbnailProbes.cs new file mode 100644 index 0000000..9704378 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/ArchiveThumbnailProbes.cs @@ -0,0 +1,267 @@ +using System.IO; +using System.Text; +using System.Windows.Media.Imaging; +using Illusion.Assets; +using Illusion.Assets.Library; +using Illusion.Viewport; + +namespace Illusion.Diagnostics.Probes; + +/// +/// The content browser's archive pictures: a handful of people and a car drawn the way a tile draws them, the +/// pictures written beside the report so they can be looked at — a face seen from behind passes every count. +/// +internal static class ArchiveThumbnailProbes +{ + // Output: %TEMP%\illusion_archive_thumbs.txt, pictures in %TEMP%\illusion_archive_thumbs\ + // Args: archive paths under pc\sds (traffic/ccerb2.sds ...); none = a default handful. + internal static void RunArchiveThumbnailProbe(string[] archives) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_archive_thumbs.txt"); + string pictures = Path.Combine(Path.GetTempPath(), "illusion_archive_thumbs"); + 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)}"); + } + + try + { + if (!ProbeAssert.InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + if (archives.Length == 0) + { + archives = ["traffic/ccerb2.sds", "traffic/cbarma.sds", "hchar/vitarmy.sds", "player/vitvez2.sds", + "police_char/polmur.sds", "cars/shubert_38.sds"]; + } + if (Directory.Exists(pictures)) Directory.Delete(pictures, recursive: true); + Directory.CreateDirectory(pictures); + + using var renderer = new ArchiveThumbnailRenderer(); + int drawn = 0, tried = 0, keptAfter = 0, olderLeft = 0; + foreach (string relative in archives) + { + var file = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", relative.Replace('/', Path.DirectorySeparatorChar))); + if (!file.Exists) { sb.AppendLine($" (not in this install: {relative})"); continue; } + string folder = "sds/" + Path.GetDirectoryName(relative)!.Replace('\\', '/'); + var entry = new LibraryEntry + { + Name = Path.GetFileNameWithoutExtension(file.Name), + File = file, + Size = file.Length, + FolderPath = folder, + Resource = LibraryResourceKinds.Of(folder), + }; + foreach (float side in new[] { 1f, -1f }) // both sides of a person, so the right one can be told by eye + { + if (side < 0 && entry.Resource == LibraryResourceKind.Car) continue; + ArchiveThumbnailRenderer.PersonFrontY = side; + string kept = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Illusion", "archive_thumbs"); + if (Directory.Exists(kept)) Directory.Delete(kept, recursive: true); // draw, do not read back + // A picture kept for this archive in an earlier state of it — what every rebuild used to leave behind. + Directory.CreateDirectory(kept); + string older = Path.Combine(kept, ArchiveThumbnailRenderer.KeyOf(entry)[..17] + "0000000000000000.png"); + File.WriteAllBytes(older, [1]); + tried++; + var clock = System.Diagnostics.Stopwatch.StartNew(); + if (renderer.Render(entry) is not BitmapSource picture) + { + sb.AppendLine($" {relative}: nothing drawn"); + continue; + } + drawn++; + // Found again under the key computed NOW — after the archive was read, and so extracted. + if (ArchiveThumbnailRenderer.TryKept(entry, out System.Windows.Media.ImageSource? again) && again != null) keptAfter++; + if (File.Exists(older)) olderLeft++; + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(picture)); + string name = $"{entry.Name}_{(side > 0 ? "plusY" : "minusY")}.png"; + using FileStream png = File.Create(Path.Combine(pictures, name)); + encoder.Save(png); + sb.AppendLine($" {relative} ({entry.Resource}) from {(side > 0 ? "+Y" : "-Y")}: {clock.ElapsedMilliseconds} ms, detail {Colours(picture)} colours -> {name}"); + } + } + Check("every archive asked for gets a picture", drawn == tried && tried > 0, $"{drawn} of {tried}"); + Check("each picture is kept and found again by the next look-up", keptAfter == drawn, $"{keptAfter} of {drawn}"); + Check("and the one kept for the archive's earlier state is dropped", olderLeft == 0, $"{olderLeft} left behind"); + + // An archive that cannot be read: a working copy whose manifest is cut short, which is not one of + // the errors anybody would think to name. It has to come back as "no picture", not as an exception — + // the tile asks from a timer tick, where an exception ends the program. + var broken = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", "cars", "illusion_probe_thumb.sds")); + string brokenCopy = MafiaEnvironment.ExtractedDir(broken); + try + { + File.WriteAllBytes(broken.FullName, [0]); + Directory.CreateDirectory(brokenCopy); + File.WriteAllText(Path.Combine(brokenCopy, "SDSContent.xml"), "FrameRes"); + broken.Refresh(); + var entry = new LibraryEntry + { + Name = "illusion_probe_thumb", File = broken, Size = broken.Length, FolderPath = "sds/cars", + Resource = LibraryResourceKind.Car, + }; + string outcome; + bool quiet; + try + { + ArchiveThumbnailRenderer.Staged staged = ArchiveThumbnailRenderer.Stage(entry); + System.Windows.Media.ImageSource? picture = renderer.Draw(staged); + quiet = staged.Empty && !staged.Passing && picture == null; + outcome = $"empty {staged.Empty}, passing {staged.Passing}"; + } + catch (Exception ex) + { + quiet = false; + outcome = ex.GetType().Name + ": " + ex.Message; + } + Check("an archive whose working copy cannot be read gets no picture and throws nothing", quiet, outcome); + Check("and that is written down, so the next run does not read it again to find out", + ArchiveThumbnailRenderer.TryKept(entry, out System.Windows.Media.ImageSource? none) && none == null); + } + finally + { + if (File.Exists(broken.FullName)) File.Delete(broken.FullName); + if (Directory.Exists(brokenCopy)) Directory.Delete(brokenCopy, recursive: true); + } + + // What a car is made of, part by part: which materials are paintwork is a question about their + // shaders and slots, and this is where it can be read off. + foreach (string relative in archives.Where(a => a.StartsWith("cars/", StringComparison.OrdinalIgnoreCase)).Take(3)) + { + var file = new FileInfo(Path.Combine(MafiaEnvironment.PcFolder, "sds", relative.Replace('/', Path.DirectorySeparatorChar))); + if (!file.Exists) continue; + sb.AppendLine($"\n parts of {relative}:"); + foreach (Illusion.Domain.MeshData mesh in Illusion.Assets.Sds.SdsMeshLoader.LoadSds(file, lod: 0)) + { + sb.AppendLine($" mesh {mesh.Name}: {mesh.Positions.Length} vertices, skinned {mesh.BoneIndices != null}"); + foreach (Illusion.Domain.MeshPart part in mesh.Parts) + { + Illusion.Domain.Materials.MaterialInfo? info = + Illusion.Assets.Materials.MafiaMaterialCatalog.Instance.GetMaterial(part.MaterialHash); + string c002 = info?.Parameters.FirstOrDefault(x => x.ParamId == "C002") is { } colour + ? string.Join(",", colour.Values.Select(v => v.ToString("0.##", System.Globalization.CultureInfo.InvariantCulture))) : "-"; + sb.AppendLine($" {part.IndexCount / 3,5} tris {info?.Name ?? "?",-26} shader {info?.ShaderId:X16}/{info?.ShaderHash:X8} " + + $"blended {part.Blended,-5} C002 {c002,-18} slots " + + string.Join(" ", (info?.TextureSlots ?? []).Select(t => $"{t.SlotId}={t.TextureName}")) + + " flags " + string.Join("|", info?.Flags ?? [])); + } + } + } + ArchiveThumbnailRenderer.PersonFrontY = 1f; + string keptDir = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Illusion", "archive_thumbs"); + if (Directory.Exists(keptDir)) Directory.Delete(keptDir, recursive: true); // the -Y pictures must not be read back as the tiles' + + // The pane itself: the street people's folder open in a real content browser, left to fill in. + LibraryCatalog catalog = LibraryCatalog.Build(Path.Combine(MafiaEnvironment.PcFolder, "sds")); + LibraryFolder? people = Find(catalog.Roots, "traffic"); + Check("the catalog has the street people's folder", people != null, people == null ? "" : $"{people.Entries.Count} archives"); + if (people != null) + { + var browser = new Views.ContentBrowser(); + var window = new System.Windows.Window + { + Width = 1280, Height = 420, Left = -4000, Top = 0, ShowInTaskbar = false, ShowActivated = false, + WindowStyle = System.Windows.WindowStyle.None, Content = browser, + }; + window.Show(); + browser.SetCatalog(catalog); + browser.OpenFolder(people); + // The pane has to stay alive while it fills: the longest the UI thread is held between two + // turns of the pump. Reading an archive used to happen on it, a second or more at a time. + var beat = System.Diagnostics.Stopwatch.StartNew(); + long longest = 0, last = 0; + var gaps = new List<(long At, long Gap)>(); + var pulse = new System.Windows.Threading.DispatcherTimer(System.Windows.Threading.DispatcherPriority.Send) + { + Interval = TimeSpan.FromMilliseconds(5), + }; + pulse.Tick += (_, _) => + { + long now = beat.ElapsedMilliseconds; + // The first three seconds hold two things that happen once per run and are not what is + // being asked about: the pane laying out a hundred and fifty tiles while the GPU stack is + // made, and the first picture drawn through shaders nothing has compiled yet. Both are in + // the list the check prints. + if (last > 0 && now - last > 60) gaps.Add((last, now - last)); + if (last > 3000) longest = Math.Max(longest, now - last); + last = now; + }; + pulse.Start(); + Pump(TimeSpan.FromSeconds(30)); + pulse.Stop(); + var images = Images(browser).ToList(); + int tiles = images.Count; + int looked = images.Count(Views.ArchiveThumb.OnScreen); + int filled = images.Count(i => Views.ArchiveThumb.OnScreen(i) && i.Source != null); + int unseenFilled = images.Count(i => !Views.ArchiveThumb.OnScreen(i) && i.Source != null); + Check("the tiles on screen get their pictures while the pane is open", looked > 0 && filled == looked, + $"{filled} of {looked} on screen ({tiles} tiles in the folder) after 30 s"); + Check("archives of tiles nobody is looking at are not read", unseenFilled == 0, $"{unseenFilled} off-screen tiles have a picture"); + Check("the UI thread is never held for long while the folder fills", longest < 400, + $"longest gap between two pumps after the first 3 s: {longest} ms; every gap over 60 ms (at ms: length): " + + string.Join(", ", gaps.Take(40).Select(g => $"{g.At}: {g.Gap}"))); + var shot = new RenderTargetBitmap((int)browser.ActualWidth, (int)browser.ActualHeight, 96, 96, + System.Windows.Media.PixelFormats.Pbgra32); + shot.Render(browser); + var png = new PngBitmapEncoder(); + png.Frames.Add(BitmapFrame.Create(shot)); + using (FileStream file = File.Create(Path.Combine(pictures, "_browser.png"))) png.Save(file); + window.Close(); + } + } + catch (Exception ex) + { + fail++; + sb.AppendLine("[FAIL] unexpected exception — " + ex); + } + finally + { + ArchiveThumbnailRenderer.PersonFrontY = 1f; + sb.Insert(0, $"ARCHIVE THUMBNAIL PROBE: {pass} passed, {fail} failed\n\n"); + File.WriteAllText(outFile, sb.ToString()); + } + } + + private static LibraryFolder? Find(IEnumerable folders, string name) + { + foreach (LibraryFolder folder in folders) + { + if (string.Equals(folder.Name, name, StringComparison.OrdinalIgnoreCase) && folder.Entries.Count > 0) return folder; + if (Find(folder.Folders, name) is { } found) return found; + } + return null; + } + + private static IEnumerable Images(System.Windows.DependencyObject root) + { + int n = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root); + for (int i = 0; i < n; i++) + { + System.Windows.DependencyObject child = System.Windows.Media.VisualTreeHelper.GetChild(root, i); + if (child is System.Windows.Controls.Image image && Views.ArchiveThumb.GetEntry(image) != null) yield return image; + foreach (System.Windows.Controls.Image inner in Images(child)) yield return inner; + } + } + + private static void Pump(TimeSpan span) + { + var frame = new System.Windows.Threading.DispatcherFrame(); + var timer = new System.Windows.Threading.DispatcherTimer { Interval = span }; + timer.Tick += (_, _) => { timer.Stop(); frame.Continue = false; }; + timer.Start(); + System.Windows.Threading.Dispatcher.PushFrame(frame); + } + + private static int Colours(BitmapSource picture) + { + var bgra = new byte[picture.PixelWidth * picture.PixelHeight * 4]; + picture.CopyPixels(bgra, picture.PixelWidth * 4, 0); + var colours = new HashSet(); + for (int i = 0; i < bgra.Length; i += 4 * 7) colours.Add(bgra[i] | bgra[i + 1] << 8 | bgra[i + 2] << 16); + return colours.Count; + } +} diff --git a/src/Illusion/Viewport/ArchiveThumbnailRenderer.cs b/src/Illusion/Viewport/ArchiveThumbnailRenderer.cs new file mode 100644 index 0000000..9d74819 --- /dev/null +++ b/src/Illusion/Viewport/ArchiveThumbnailRenderer.cs @@ -0,0 +1,641 @@ +using System.IO; +using System.Numerics; +using System.Windows.Media; +using System.Windows.Media.Imaging; +using Illusion.Assets.Library; +using Illusion.Assets.Sds; +using Illusion.Domain; +using Illusion.Formats.Hashing; +using Illusion.Rendering.Gpu; +using Illusion.Rendering.Passes; + +namespace Illusion.Viewport; + +/// +/// The picture on an archive tile of the content browser: what the archive holds, drawn on its own with its own +/// textures. A person is framed head and shoulders — at tile size that is the part that tells one skin from +/// the next; a car is shown whole and on its wheels, from the front quarter. +/// +/// The same shape as : a headless GPU stack created on first use, an +/// offscreen target that is read back, and the picture kept on disk — keyed by the archive's path, size and +/// time stamp, so a rebuilt archive draws again and an untouched one never does. Drawing one costs reading the +/// archive (and extracting it the first time), which is why the browser asks for pictures one at a time, for +/// the kinds named by only. +/// +/// +/// In two halves, so that the costly one stays off the UI thread: reads the archive and +/// works out what to show and from where — any thread, nothing of the GPU — and puts that +/// on the offscreen target, UI thread only. Neither throws: a tile whose archive cannot be read keeps its +/// icon, and a timer tick is no place for an exception to surface — there it takes the program down. +/// +/// +internal sealed class ArchiveThumbnailRenderer : IDisposable +{ + public const int Width = 192; + public const int Height = 128; + + private GpuContext? _gpu; + private SceneRenderer? _renderer; + private SharedRenderTarget? _target; + private bool _failed; + + private static string CacheDir => Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Illusion", "archive_thumbs"); + + /// Whether an archive of this kind is one model worth a picture. A district or a sound bank is + /// not: the first is a scene of thousands of objects, the second has nothing to draw. + public static bool Draws(LibraryResourceKind kind) => + IsPerson(kind) || kind is LibraryResourceKind.Wardrobe or LibraryResourceKind.Car; + + // A figure standing upright — as against a wardrobe archive, which is a garment on a hanger. + private static bool IsPerson(LibraryResourceKind kind) => kind is LibraryResourceKind.Character + or LibraryResourceKind.Player or LibraryResourceKind.Police or LibraryResourceKind.Traffic; + + /// What is kept for an archive, without drawing anything. False when nothing is kept yet; true + /// with a null picture for an archive already found to hold nothing a tile can show. + public static bool TryKept(LibraryEntry entry, out ImageSource? picture) + { + picture = null; + string stem = StemOf(entry); + if (File.Exists(stem + ".png")) + { + picture = Load(stem + ".png"); + if (picture != null) return true; + } + return File.Exists(stem + NothingSuffix); + } + + /// What names the state of an archive a kept picture was made from: its path, size and time + /// stamp and its working copy's. A picture shown under another key is out of date. + public static string KeyOf(LibraryEntry entry) => Path.GetFileName(StemOf(entry)); + + /// What worked out for one archive: what to draw, where its textures are + /// and where the camera stands. + public sealed class Staged + { + public required LibraryEntry Entry { get; init; } + public List Meshes { get; init; } = []; + public List TextureFolders { get; init; } = []; + public Vector3 Centre { get; init; } + public Vector3 From { get; init; } + public float Radius { get; init; } + public float Fov { get; init; } + + /// Nothing to draw. + public bool Empty => Meshes.Count == 0; + + /// Empty because the archive could not be READ this time (a file held by something else) — + /// as against an archive that holds nothing to show, which stays so until it changes. + public bool Passing { get; init; } + } + + /// The picture for one archive: the kept one, or a fresh one drawn and kept. Null when the archive + /// holds nothing that can be drawn — the tile then keeps its icon. + public ImageSource? Render(LibraryEntry entry) => + TryKept(entry, out ImageSource? kept) ? kept : Draw(Stage(entry)); + + /// + /// Reads an archive (extracting it the first time) and works out its picture: the meshes, the folders + /// their textures are in, the camera. Any thread; touches nothing of the GPU. Never throws — an archive + /// that cannot be read, or holds nothing to show, comes back . + /// + public static Staged Stage(LibraryEntry entry) + { + if (!Draws(entry.Resource)) return new Staged { Entry = entry }; + try + { + Assets.MafiaMaterials.EnsureLoaded(); + List meshes = SdsMeshLoader.LoadSds(entry.File, lod: 0); + if (meshes.Count == 0) return new Staged { Entry = entry }; + + bool car = entry.Resource == LibraryResourceKind.Car; + var folders = new List { Assets.MafiaEnvironment.ExtractedDir(entry.File) }; + // A car takes its chrome, glass and lamp textures from the shared car library (see + // StageCompanions); without that folder two thirds of what its materials name is white. + foreach (FileInfo companion in StageCompanions.For(entry.File)) + { + try { folders.Add(SdsMeshLoader.EnsureExtracted(companion)); } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // a missing library is a duller car, not a missing picture + } + } + + // A car is shown the way it stands in the street: on its wheels, without the parts that are + // only there some of the time, and without the helper volumes its archive also holds. + if (car) meshes = CarForShow(entry.File, meshes); + if (meshes.Count == 0) return new Staged { Entry = entry }; + + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + foreach (MeshData mesh in meshes) + { + (Vector3 lo, Vector3 hi) = BoundsOf(mesh); + min = Vector3.Min(min, lo); + max = Vector3.Max(max, hi); + } + if (min.X > max.X) return new Staged { Entry = entry }; + + Vector3 centre; + float radius; + Vector3 from; + float fov = MathF.PI / 3f; // the viewport's own; a car narrows it below + if (IsPerson(entry.Resource)) + { + // Head and shoulders: the top fifth of the figure is the head, the shoulders end a little + // under a third of the way down. Seen from the front, a touch to one side and from above — + // dead ahead flattens a face into a mask. + float height = max.Z - min.Z; + centre = new Vector3((min.X + max.X) / 2f, (min.Y + max.Y) / 2f, max.Z - height * 0.155f); + radius = MathF.Max(0.05f, height * 0.17f); + from = Vector3.Normalize(new Vector3(0.32f, PersonFrontY, 0.10f)); + } + else if (car) + { + // The whole car, from the front quarter and a little above — the angle a car is recognized + // from — through a long lens: at the viewport's wide angle the near wing swells and the tail + // shrinks away, and a tile of that is mostly empty. + fov = CarLens; + from = Vector3.Normalize(new Vector3(0.66f, 0.75f, 0.27f)); + Vector3 right = Vector3.Normalize(Vector3.Cross(Vector3.UnitZ, from)); + Vector3 up = Vector3.Cross(from, right); + float tan = MathF.Tan(CarLens / 2f), aspect = (float)Width / Height; + + // Fitted to what is DRAWN, vertex by vertex, not to a box round it: the nearest the camera + // can stand with every vertex in the picture, then the aim moved to the middle of what that + // shows, and again — a point nearer the camera takes up more of the frame, so the middle of + // the car is not the middle of its picture. + List drawn = DrawnPoints(meshes); + centre = (min + max) / 2f; + float fit = 1f; + for (int pass = 0; pass < 4; pass++) + { + fit = 0f; + foreach (Vector3 point in drawn) + { + Vector3 offset = point - centre; + float across = MathF.Abs(Vector3.Dot(offset, right)) / (tan * aspect); + float upward = MathF.Abs(Vector3.Dot(offset, up)) / tan; + fit = MathF.Max(fit, Vector3.Dot(offset, from) + MathF.Max(across, upward)); + } + float left = float.MaxValue, rightmost = float.MinValue, low = float.MaxValue, high = float.MinValue; + foreach (Vector3 point in drawn) + { + Vector3 offset = point - centre; + float depth = fit - Vector3.Dot(offset, from); + if (depth <= 0.01f) continue; + float x = Vector3.Dot(offset, right) / depth, y = Vector3.Dot(offset, up) / depth; + left = MathF.Min(left, x); + rightmost = MathF.Max(rightmost, x); + low = MathF.Min(low, y); + high = MathF.Max(high, y); + } + if (left > rightmost) break; + centre += (right * ((left + rightmost) / 2f * fit)) + (up * ((low + high) / 2f * fit)); + } + // Turned back into the "radius" the shared camera code in Draw works from (which adds its + // own margin). + radius = MathF.Max(0.05f, fit * MathF.Sin(CarLens / 2f)); + } + else + { + // A garment on its hanger: all of it, from the same side a person is looked at from. + centre = (min + max) / 2f; + radius = MathF.Max(0.05f, (max - min).Length() / 2f * 0.85f); + from = Vector3.Normalize(new Vector3(0.32f, PersonFrontY, 0.10f)); + } + + if (!float.IsFinite(radius) || !float.IsFinite(centre.X + centre.Y + centre.Z)) return new Staged { Entry = entry }; + return new Staged + { + Entry = entry, Meshes = meshes, TextureFolders = folders, Centre = centre, From = from, Radius = radius, Fov = fov, + }; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + return new Staged { Entry = entry, Passing = true }; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // Everything else a broken archive can throw on the way in — a manifest that is not XML, a frame + // type nobody knows, an index past the end of a buffer. The list is not knowable in advance, and + // the answer to each is the same: this archive has no picture. + return new Staged { Entry = entry }; + } + } + + /// Draws what worked out and keeps the picture. UI thread. Null — and the + /// tile keeps its icon — when there is nothing to draw or the GPU would not have it; never throws. + public ImageSource? Draw(Staged staged) + { + ArgumentNullException.ThrowIfNull(staged); + if (staged.Empty) + { + if (!staged.Passing) NoteNothing(staged.Entry); + return null; + } + if (!EnsureContext()) return null; + + try + { + // One archive at a time, and only ITS folders: a texture is taken from the first folder that + // has the name, so folders left from the archive before would dress this one in its textures. + _renderer!.Clear(); + _renderer.Textures.ClearFolders(); + foreach (string folder in staged.TextureFolders) _renderer.Textures.AddFolder(folder); + foreach (MeshData mesh in staged.Meshes) _renderer.AddMesh(mesh); + + _renderer.Camera.Fov = staged.Fov; + float distance = staged.Radius / MathF.Sin(staged.Fov / 2f) * 1.05f; + _renderer.Camera.Near = MathF.Max(0.01f, distance - staged.Radius * 3f); + _renderer.Camera.Far = distance + staged.Radius * 12f; + _renderer.Camera.LookAt(staged.Centre + staged.From * distance, staged.Centre); + _renderer.Render(_target!); + + byte[] bgra = RenderTargetReadback.Read(_gpu!, _target!); + BitmapSource bmp = BitmapSource.Create(Width, Height, 96, 96, PixelFormats.Bgra32, null, bgra, Width * 4); + bmp.Freeze(); + Save(bmp, staged.Entry); + return bmp; + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // A texture that will not decode, a device the driver took away. Not written down: the next run + // may well draw it. + return null; + } + finally + { + try { _renderer?.Clear(); } // the meshes are not held until the next tile comes along + catch (Exception ex) when (ex is not OutOfMemoryException) { /* nothing more to release */ } + } + } + + // About a 100 mm lens on a 35 mm frame — what a car is photographed with. + private const float CarLens = 0.36f; + + /// Every vertex some triangle actually draws, in the world — a part emptied by + /// and a triangle collapsed there contribute nothing. + private static List DrawnPoints(List meshes) + { + var points = new List(); + foreach (MeshData mesh in meshes) + { + var seen = new bool[mesh.Positions.Length]; + foreach (MeshPart part in mesh.Parts) + { + int end = Math.Min(part.StartIndex + part.IndexCount, mesh.Indices.Length); + for (int i = part.StartIndex; i + 2 < end; i += 3) + { + if (mesh.Indices[i] == mesh.Indices[i + 1] && mesh.Indices[i] == mesh.Indices[i + 2]) continue; + for (int corner = 0; corner < 3; corner++) + { + int vertex = (int)mesh.Indices[i + corner]; + if (vertex >= seen.Length || seen[vertex]) continue; + seen[vertex] = true; + points.Add(Vector3.Transform(mesh.Positions[vertex], mesh.World)); + } + } + } + } + return points; + } + + private static (Vector3 Min, Vector3 Max) BoundsOf(MeshData mesh) + { + Vector3 min = new(float.MaxValue), max = new(float.MinValue); + foreach (Vector3 p in mesh.Positions) + { + Vector3 w = Vector3.Transform(p, mesh.World); + min = Vector3.Min(min, w); + max = Vector3.Max(max, w); + } + return (min, max); + } + + // Glass and snow, by shader: a car's windows carry the broken-glass texture the game only shows after a + // crash, and the snow layer is a second skin the game puts on in winter. Neither is the car. + private const ulong GlassShader = 0x23767C2D47662E58; + private const ulong SnowShader = 0x53BA752812971810; + + /// + /// A car as it stands in the street, from what its archive holds. + /// + /// The archive is the car AND its states: the snow it wears in winter, the supercharger and the side + /// exhaust a tuning shop may fit, windows textured as broken glass — all present at once, all on bones or + /// materials of their own. It also holds volumes that are not geometry at all (the rain shelter, emitters), + /// which name no material and draw as white blocks over the roof. And it holds no wheels: those live in + /// the shared car library and are put on by the game. + /// + /// So: meshes with no known material are left out, the sometimes-parts are dropped triangle by triangle, + /// and a wheel from the library is stood on every axle bone. + /// + private static List CarForShow(FileInfo archive, List meshes) + { + var shown = new List(); + MeshData? body = null; + foreach (MeshData mesh in meshes) + { + if (!mesh.Parts.Any(part => Assets.MafiaMaterials.KnowsMaterial(part.MaterialHash))) continue; + + var parts = new MeshPart[mesh.Parts.Length]; + for (int i = 0; i < parts.Length; i++) + { + MeshPart part = mesh.Parts[i]; + ulong shader = Assets.Materials.MafiaMaterialCatalog.Instance.GetMaterial(part.MaterialHash)?.ShaderId ?? 0; + parts[i] = shader is GlassShader or SnowShader + ? new MeshPart(part.StartIndex, 0, part.DiffuseTexture, part.NormalTexture, part.SpecularTexture, + part.MaterialHash, part.Tint, part.Blended) + : part; + } + + // The sometimes-parts that share a material with the rest: by the bone their vertices ride. + uint[] indices = mesh.Indices; + if (mesh.Skeleton is { } rig && mesh.BoneIndices is { } ids && mesh.BoneWeights is { } weights) + { + var optional = new bool[rig.Bones.Count]; + bool any = false; + for (int bone = 0; bone < optional.Length; bone++) + { + // "turbo_charger" on one car is "turbo charger" on the next. + string name = rig.Bones[bone].Name.Replace("_", "", StringComparison.Ordinal) + .Replace(" ", "", StringComparison.Ordinal); + optional[bone] = name.StartsWith("snow", StringComparison.OrdinalIgnoreCase) + || name.Equals("turbocharger", StringComparison.OrdinalIgnoreCase) + || name.Equals("exhaustcustom", StringComparison.OrdinalIgnoreCase); + any |= optional[bone]; + } + if (any) + { + indices = (uint[])indices.Clone(); + for (int i = 0; i + 2 < indices.Length; i += 3) + { + int vertex = (int)indices[i]; + int best = 0; + for (int k = 1; k < 4; k++) + if (weights[(vertex * 4) + k] > weights[(vertex * 4) + best]) best = k; + int bone = ids[(vertex * 4) + best]; + // A triangle with no area is a triangle not drawn. + if (bone < optional.Length && optional[bone]) indices[i + 1] = indices[i + 2] = indices[i]; + } + } + } + + var copy = new MeshData + { + Name = mesh.Name, + World = mesh.World, + Positions = mesh.Positions, + Normals = mesh.Normals, + UVs = mesh.UVs, + Tangents = mesh.Tangents, + Binormals = mesh.Binormals, + Indices = indices, + Parts = parts, + Lod = mesh.Lod, + BoneIndices = mesh.BoneIndices, + BoneWeights = mesh.BoneWeights, + Skeleton = mesh.Skeleton, + LiveRest = mesh.LiveRest, + }; + shown.Add(copy); + if (copy.Skeleton != null && (body == null || copy.Positions.Length > body.Positions.Length)) body = copy; + } + + if (body?.Skeleton is { } bones) shown.AddRange(Wheels(archive, body, bones)); + return shown; + } + + private static List? _wheelLibrary; + private static readonly object WheelSync = new(); + + // The shared car library's meshes, read once and kept: every car's wheels come out of it. + private static List WheelLibrary(FileInfo archive) + { + lock (WheelSync) + { + if (_wheelLibrary != null) return _wheelLibrary; + var library = new List(); + foreach (FileInfo file in StageCompanions.For(archive)) + { + try { library.AddRange(SdsMeshLoader.LoadSds(file, lod: 0)); } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // no library, no wheels — the car is still worth a picture + } + } + return _wheelLibrary = library; + } + } + + /// + /// One wheel per hub. A car's rig marks each hub with a brake-drum bone (the axle bones sit on the car's + /// centre line — they are the pivots, not where a wheel goes), and the shared library holds the wheels at + /// their real size. Which of them a car wears is a row of its tuning table; a tile only has to say "a car + /// on its wheels", so the choice here is by the kind of vehicle — a plain road wheel, the jeep's, a + /// lorry's — and the wheel is not scaled. + /// + private static IEnumerable Wheels(FileInfo archive, MeshData body, SkeletonData rig) + { + List wheels = WheelLibrary(archive); + + (Vector3 lo, Vector3 hi) = BoundsOf(body); + string name = Path.GetFileNameWithoutExtension(archive.Name); + bool lorry = name.StartsWith("hank", StringComparison.OrdinalIgnoreCase); + string[] front, rear; + if (lorry) (front, rear) = (["wheel hankF"], ["wheel HankB"]); + else if (name.Contains("jeep", StringComparison.OrdinalIgnoreCase)) front = rear = ["pneu_jeep", "wheel jeep1"]; + else if (hi.Y - lo.Y > 6.5f) front = rear = ["wheel_truck01", "wheel_truck02"]; + else front = rear = ["wheel_civil01"]; + + MeshData? Find(string[] wanted) => + wanted.Select(w => wheels.FirstOrDefault(m => m.Name.Equals(w, StringComparison.OrdinalIgnoreCase))) + .FirstOrDefault(m => m != null) + ?? wheels.FirstOrDefault(m => m.Name.StartsWith("wheel_civil", StringComparison.OrdinalIgnoreCase)); + + foreach (BoneData bone in rig.Bones) + { + // "brake_drumFL", "brake drumBR2", "BRAKE DRUM RL" — one bone per hub, spelled three ways. + string plain = bone.Name.Replace("_", "", StringComparison.Ordinal).Replace(" ", "", StringComparison.Ordinal); + if (!plain.StartsWith("brakedrum", StringComparison.OrdinalIgnoreCase)) continue; + Vector3 at = bone.Rest.Translation; + if (Find(at.Y >= (lo.Y + hi.Y) / 2f ? front : rear) is not { } wheel) continue; + + // The wheel's own shape: centred on its hub, thinnest along its axle. + Vector3 wheelLo = new(float.MaxValue), wheelHi = new(float.MinValue); + foreach (Vector3 p in wheel.Positions) + { + wheelLo = Vector3.Min(wheelLo, p); + wheelHi = Vector3.Max(wheelHi, p); + } + Vector3 hub = (wheelLo + wheelHi) / 2f, extent = wheelHi - wheelLo; + Matrix4x4 upright = extent.X <= extent.Y && extent.X <= extent.Z ? Matrix4x4.Identity + : extent.Y <= extent.Z ? Matrix4x4.CreateRotationZ(MathF.PI / 2f) + : Matrix4x4.CreateRotationY(MathF.PI / 2f); + + Matrix4x4 place = Matrix4x4.CreateTranslation(-hub) * upright + // The far side's wheel is the same wheel turned round, so its face looks outward too. + * (at.X < 0f ? Matrix4x4.CreateRotationZ(MathF.PI) : Matrix4x4.Identity) + * Matrix4x4.CreateTranslation(at) * body.World; + yield return new MeshData + { + Name = "wheel " + bone.Name, + World = place, + Positions = wheel.Positions, + Normals = wheel.Normals, + UVs = wheel.UVs, + Tangents = wheel.Tangents, + Binormals = wheel.Binormals, + Indices = wheel.Indices, + Parts = wheel.Parts, + Lod = 0, + }; + } + } + + /// Which way a person's model faces along Y: the camera stands on that side. + internal static float PersonFrontY { get; set; } = 1f; + + // Bumped when what a tile shows changes — the framing, what is left off a car — so that pictures kept by + // an older build are not handed out as this one's. + private const int Look = 2; + private const string NothingSuffix = ".none"; + + // "_": the first half is the archive, the second its size and time stamp, its working + // copy's time stamp (the picture is drawn from the working copy, which a save changes without touching + // the packed archive) and the look. One archive's pictures share the first half, which is how the ones + // a new picture replaces are found. + private static string StemOf(LibraryEntry entry) + { + var file = new FileInfo(entry.File.FullName); + long copy = 0; + try + { + string extracted = Assets.MafiaEnvironment.ExtractedDir(file); + if (Directory.Exists(extracted)) copy = Directory.GetLastWriteTimeUtc(extracted).Ticks; + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException + or InvalidOperationException or NullReferenceException) + { + // no environment, or a path outside the game: then there is no working copy to speak of + } + string state = file.Exists ? $"{file.Length}|{file.LastWriteTimeUtc.Ticks}|{copy}|{Look}" : $"gone|{Look}"; + return Path.Combine(CacheDir, $"{Fnv64.Hash(file.FullName.ToLowerInvariant()):x16}_{Fnv64.Hash(state):x16}"); + } + + // The files kept for the same archive in an earlier state: each rebuild used to leave one behind for good. + private static void DropOlder(string stem) + { + string whose = Path.GetFileName(stem)[..17]; // sixteen digits and the underscore + foreach (string other in Directory.GetFiles(CacheDir, whose + "*")) + { + if (!Path.GetFileNameWithoutExtension(other).Equals(Path.GetFileName(stem), StringComparison.OrdinalIgnoreCase)) + { + File.Delete(other); + } + } + if (_legacyDropped) return; + _legacyDropped = true; + // Pictures under the name the first version of this gave them (no underscore): nothing reads them. + foreach (string old in Directory.GetFiles(CacheDir, "*.png")) + { + if (!Path.GetFileName(old).Contains('_')) File.Delete(old); + } + } + + private static bool _legacyDropped; + + // An archive with nothing a tile can show: written down, so that the next run does not extract and read + // it again to find that out. + private static void NoteNothing(LibraryEntry entry) + { + try + { + Directory.CreateDirectory(CacheDir); + string stem = StemOf(entry); + File.WriteAllBytes(stem + NothingSuffix, []); + DropOlder(stem); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // not written down: it is found out again next time + } + } + + private static ImageSource? Load(string path) + { + try + { + var bmp = new BitmapImage(); + bmp.BeginInit(); + bmp.CacheOption = BitmapCacheOption.OnLoad; // read now: the file must not stay locked + bmp.UriSource = new Uri(path); + bmp.EndInit(); + bmp.Freeze(); + return bmp; + } + catch (Exception ex) when (ex is IOException or NotSupportedException or UriFormatException) + { + return null; + } + } + + // Keyed AFTER the archive was read: reading it the first time extracts it, and the working copy that + // then exists is part of the key the next look-up will compute. + private static void Save(BitmapSource bmp, LibraryEntry entry) + { + try + { + Directory.CreateDirectory(CacheDir); + string stem = StemOf(entry); + var encoder = new PngBitmapEncoder(); + encoder.Frames.Add(BitmapFrame.Create(bmp)); + using (FileStream file = File.Create(stem + ".png")) encoder.Save(file); + DropOlder(stem); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // not keeping the picture costs drawing it again next time, nothing more + } + } + + /// Whether there is a GPU to draw on — false once creating one has failed, and then there is + /// no point reading archives for pictures nobody can draw. + public bool Usable => EnsureContext(); + + private bool EnsureContext() + { + if (_renderer != null) return true; + if (_failed) return false; + try + { + _gpu = new GpuContext(); + _renderer = new SceneRenderer(_gpu) + { + Mode = RenderMode.MaterialPreview, + ShowSky = false, + // Nothing behind the figure: the tile's own background, hover and selection show through, + // where a filled picture would sit on the tile as a darker box. + ClearColor = new Vector4(0f, 0f, 0f, 0f), + }; + _renderer.Textures.SetFallbackResolver(Assets.Textures.TextureSearchIndex.FindPath); + _target = new SharedRenderTarget(_gpu, Width, Height); + return true; + } + catch + { + Dispose(); + _failed = true; + return false; + } + } + + public void Dispose() + { + _renderer?.Dispose(); + _target?.Dispose(); + _gpu?.Dispose(); + _renderer = null; + _target = null; + _gpu = null; + } +} diff --git a/src/Illusion/Views/ArchiveThumb.cs b/src/Illusion/Views/ArchiveThumb.cs new file mode 100644 index 0000000..7170f06 --- /dev/null +++ b/src/Illusion/Views/ArchiveThumb.cs @@ -0,0 +1,246 @@ +using System.Runtime.CompilerServices; +using System.Windows; +using System.Windows.Controls; +using System.Windows.Media; +using System.Windows.Threading; +using Illusion.Assets.Library; +using Illusion.Viewport; + +namespace Illusion.Views; + +/// +/// Puts an archive's picture on an of a content browser tile: +/// v:ArchiveThumb.Entry="{Binding}". The picture arrives later than the tile — a kept one within a tick +/// or two, a new one once its archive has been read and drawn, one archive at a time. +/// +/// Only tiles that are on screen have their archives read. Reading one the first time extracts it — a working +/// copy of tens of megabytes — and a folder of a hundred cars opened for a glance is not a request to extract +/// a hundred cars; the rest are read when they are scrolled to. The reading itself runs off the UI thread +/// (), so the pane and the viewport behind it stay alive while it +/// does; only the drawing happens here. +/// +/// +/// The entry is a plain catalog row with nowhere to hold a picture and no way to say it changed, which is why +/// this is an attached property on the Image rather than a binding to the row. +/// +/// +public static class ArchiveThumb +{ + public static readonly DependencyProperty EntryProperty = DependencyProperty.RegisterAttached( + "Entry", typeof(LibraryEntry), typeof(ArchiveThumb), new PropertyMetadata(null, OnEntryChanged)); + + public static LibraryEntry? GetEntry(DependencyObject o) => (LibraryEntry?)o.GetValue(EntryProperty); + + public static void SetEntry(DependencyObject o, LibraryEntry? value) => o.SetValue(EntryProperty, value); + + // What this run already knows about an archive: its picture — null for one with nothing to show, which is + // not tried again — and the state of the archive that picture was made from. + private sealed class Slot(string key, ImageSource? picture) + { + public readonly string Key = key; + public readonly ImageSource? Picture = picture; + } + + // Held by the catalog row, weakly: a catalog that is rebuilt (every time a resource window opens) takes its + // rows' pictures with it, where a dictionary keyed by the row kept every catalog's pictures for good. + private static readonly ConditionalWeakTable Known = new(); + private static readonly List Waiting = new(); + + // A tile waiting for its picture. A tile that left the pane (another folder was opened) keeps its Image + // alive for a while and never says so — it is simply no longer loaded, and after a second of that it is + // dropped rather than asked about for ever. One that comes back is queued again by its Loaded event. + private sealed class Pending(Image image) + { + public readonly WeakReference Image = new(image); + public int Unloaded; + } + + private static readonly TimeSpan Busy = TimeSpan.FromMilliseconds(20); + private static readonly TimeSpan Idle = TimeSpan.FromMilliseconds(250); // only tiles off screen are waiting + + private static ArchiveThumbnailRenderer? _renderer; + private static DispatcherTimer? _timer; + + // The one archive being read, on a pool thread, and whose it is. + private static Task? _reading; + private static LibraryEntry? _readingFor; + + private static void OnEntryChanged(DependencyObject d, DependencyPropertyChangedEventArgs e) + { + if (d is not Image image) return; + image.Source = null; + image.Loaded -= OnLoaded; + if (e.NewValue is not LibraryEntry entry || !ArchiveThumbnailRenderer.Draws(entry.Resource)) return; + image.Loaded += OnLoaded; + if (Known.TryGetValue(entry, out Slot? slot)) + { + // Checked each time a tile is made for it: an archive rebuilt since its picture was taken is a + // different picture, and the row it hangs on is the same row. + if (slot.Key == ArchiveThumbnailRenderer.KeyOf(entry)) + { + image.Source = slot.Picture; + return; + } + Known.Remove(entry); + } + Queue(image); + } + + private static void OnLoaded(object sender, RoutedEventArgs e) + { + if (sender is Image { Source: null } image && GetEntry(image) is { } entry && !Known.TryGetValue(entry, out _)) + { + Queue(image); + } + } + + private static void Queue(Image image) + { + foreach (Pending pending in Waiting) + { + if (pending.Image.TryGetTarget(out Image? queued) && ReferenceEquals(queued, image)) return; + } + Waiting.Add(new Pending(image)); + DispatcherTimer timer = Timer(); + timer.Interval = Busy; + timer.Start(); + } + + private static DispatcherTimer Timer() + { + if (_timer != null) return _timer; + _timer = new DispatcherTimer(DispatcherPriority.Background) { Interval = Busy }; + _timer.Tick += (_, _) => Tick(); + if (Application.Current is { } app) + { + app.Exit += (_, _) => + { + _timer.Stop(); + _renderer?.Dispose(); + _renderer = null; + }; + } + return _timer; + } + + private static void Tick() + { + try + { + Work(); + } + catch (Exception ex) when (ex is not OutOfMemoryException) + { + // An exception out of a timer tick has nothing above it: it ends the program, with whatever is + // unsaved in the editor. Pictures are not worth that. The queue is dropped rather than kept — + // what threw would throw again twenty milliseconds from now. + Waiting.Clear(); + _timer?.Stop(); + } + } + + private static void Work() + { + var live = new List<(Image Image, LibraryEntry Entry)>(); + for (int i = Waiting.Count - 1; i >= 0; i--) + { + Pending pending = Waiting[i]; + if (!pending.Image.TryGetTarget(out Image? image) || GetEntry(image) is not { } entry) + { + Waiting.RemoveAt(i); + continue; + } + if (Known.TryGetValue(entry, out Slot? done)) + { + image.Source = done.Picture; + Waiting.RemoveAt(i); + continue; + } + if (image.IsLoaded) + { + pending.Unloaded = 0; + live.Add((image, entry)); + } + else if (++pending.Unloaded > 50) + { + Waiting.RemoveAt(i); + } + } + + // An archive that has been read: draw it. Its tile takes the picture at the top of the next tick. + if (_reading is { IsCompleted: true } read && _readingFor is { } readFor) + { + _reading = null; + _readingFor = null; + ImageSource? picture = read.IsCompletedSuccessfully + ? (_renderer ??= new ArchiveThumbnailRenderer()).Draw(read.Result) + : null; + Remember(readFor, picture); + return; + } + + if (Waiting.Count == 0) + { + if (_reading == null) _timer?.Stop(); + return; + } + if (live.Count == 0) return; + live.Reverse(); // back in the order the tiles were made + + // Kept pictures cost a file read each: a handful per tick, the ones on screen first. + var onScreen = live.Where(l => OnScreen(l.Image)).ToList(); + int handed = 0; + foreach ((Image image, LibraryEntry entry) in onScreen.Concat(live)) + { + if (handed >= 10) break; + if (Known.TryGetValue(entry, out _)) continue; + if (!ArchiveThumbnailRenderer.TryKept(entry, out ImageSource? kept)) continue; + Remember(entry, kept); + image.Source = kept; + handed++; + } + if (handed > 0 || _reading != null) + { + _timer!.Interval = Busy; + return; + } + + // Nothing kept is left to hand out: read ONE archive — one that is being looked at. + if (onScreen.Count == 0) + { + _timer!.Interval = Idle; + return; + } + _renderer ??= new ArchiveThumbnailRenderer(); + if (!_renderer.Usable) + { + Waiting.Clear(); // no GPU to draw on: reading archives would be for nothing + _timer!.Stop(); + return; + } + LibraryEntry next = onScreen[0].Entry; + _readingFor = next; + _reading = Task.Run(() => ArchiveThumbnailRenderer.Stage(next)); + _timer!.Interval = Busy; + } + + private static void Remember(LibraryEntry entry, ImageSource? picture) => + Known.AddOrUpdate(entry, new Slot(ArchiveThumbnailRenderer.KeyOf(entry), picture)); + + internal static bool OnScreen(Image image) + { + if (!image.IsVisible) return false; + DependencyObject? parent = VisualTreeHelper.GetParent(image); + while (parent != null && parent is not ScrollViewer) parent = VisualTreeHelper.GetParent(parent); + if (parent is not ScrollViewer scroller) return true; + try + { + Point at = image.TransformToAncestor(scroller).Transform(new Point(0, 0)); + return at.Y > -120 && at.Y < scroller.ViewportHeight + 20; + } + catch (InvalidOperationException) + { + return false; // not in that tree any more + } + } +} diff --git a/src/Illusion/Views/ContentBrowser.xaml b/src/Illusion/Views/ContentBrowser.xaml index ce3bb7c..924b315 100644 --- a/src/Illusion/Views/ContentBrowser.xaml +++ b/src/Illusion/Views/ContentBrowser.xaml @@ -240,9 +240,10 @@ - + + HorizontalAlignment="Center" VerticalAlignment="Center" + Visibility="Collapsed" /> + + + @@ -516,6 +528,11 @@ TextAlignment="Center" Margin="0,2,0,0" Text="{Binding Size, Converter={StaticResource ByteSize}}" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - public D3DImageHost.BuildReport BuildEdits(bool createBackup = true) + public D3DImageHost.BuildReport BuildEdits(bool createBackup = true) => BuildArchives(_editedArchives.Values.ToList(), createBackup); + + /// for a chosen set: the archives ticked in the Build window. One that is + /// on the build list and is not among them stays on it; one that is not on the list at all - a working + /// copy changed by hand, added in the window - is packed like the rest. + public D3DImageHost.BuildReport BuildArchives(IReadOnlyList archives, bool createBackup = true) { SaveEdits(); // ensure the extracted folders match memory before we pack them var packed = new List(); var failed = new List(); DateTime when = DateTime.Now; // one stamp for the whole build, so co-packed archives group in backups\ - foreach (FileInfo sds in _editedArchives.Values.ToList()) + foreach (FileInfo sds in archives) { try { diff --git a/src/Illusion/Views/BuildWindow.xaml b/src/Illusion/Views/BuildWindow.xaml new file mode 100644 index 0000000..b04d390 --- /dev/null +++ b/src/Illusion/Views/BuildWindow.xaml @@ -0,0 +1,121 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +