diff --git a/README.md b/README.md index fb73f7b..d538f83 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,10 @@ The last path is remembered. A green download arrow appears beside the gear when out; the same progress bar shows the download, and the toolkit restarts to install it. *(A "Resource Editor" tile exists but is a disabled stub.)* +The library browser shows a picture of each character, wardrobe piece and car, drawn from its +archive: people head and shoulders, a car on its wheels. Pictures are kept on disk and only the +tiles on screen are read. + ### Viewport and streaming - Area catalog of open-world districts and interiors, from `cityareas.bin`. @@ -200,6 +204,9 @@ with it: *Blended* becomes a translucent surface (glass), anything else a cut-ou grille). The material is written to the library on Save and its textures are packed into the archive on Build. +A skinned mesh - a car body - can gain vertices: its remap pools are rebuilt from the pushed skin. +Two levels of detail of one object can go in one push. A person opens standing on their rig. + Limits worth knowing: untouched geometry round-trips bit-exactly (that is how a real reshape is told apart from an untouched one); a topology rebuild leaves lower LODs and collision with the old shape; collision placements refuse scale and mirror pushes (resize the hull with the toolkit's own @@ -209,9 +216,15 @@ split it into several objects. ### Saving, building, backups -**Ctrl+S** writes edited FrameResources back to the extracted mirror. **Build** repacks every -edited archive into its `.sds`, creating a timestamped versioned backup first; archives are packed -independently, so one failure (the game holding a file open, say) does not block the rest. +**Ctrl+S** writes edited FrameResources back to the extracted mirror. **Build** opens a window +first: every archive edited in the session, ticked, and under each the files of its working copy that +differ from the archive the game has now. A Build packs a whole working copy, so that list is +everything that is about to change in the game - including a file changed in that folder in an +earlier session and forgotten, which is marked and counted out loud. Untick an archive to leave it +for a later Build; **Add archive…** packs one whose working copy was changed by hand (a script, a +table). Each ticked archive is then repacked into its `.sds` with a timestamped versioned backup +first; archives are packed independently, so one failure (the game holding a file open, say) does +not block the rest. **Restore Backup** rolls a single archive back to an earlier version - replacing both the live `.sds` and its extracted mirror - from the File menu, the tree's context menu or the viewport's. *(Material-library edits are not covered by the backup flow.)* @@ -233,6 +246,25 @@ beside the working copy in `illusion_memory.json`, and stated again on every Bui resource changed size. A repacked stock car states exactly what the original did (`--probe-car-clone`). +### Tools menu + +What the MCP server can do to a scene, the menus can too: each item under **Tools** opens a window +over the same job the matching tool runs, so a result does not depend on who asked for it. + +- **Hide triangles…** (map and resource editor) - cuts an opening in the selected mesh without + rebuilding it: a doorway in a stock wall, a pane of glass. With the window open the triangles are + picked by clicking them on the mesh in the viewport (a click on a marked one takes it back), or - + for many at once - by a box that holds them. What would be hidden is marked on the mesh, the count + says what goes on each level of detail, and Hide is one undo step. +- **Mirror to winter…** (map editor) - carries the loaded district's edits into its winter archive. + The item is greyed out, and says why, when the district has no winter variant or the winter one is + what is loaded. +- **Stream map…** - find and replace across the text of a `StreamMapa.bin` (archive paths, instance, + line and group names), with every string it would change listed before anything is written and a + backup kept beside the file. +- **Clone car…**, **Replace a car…**, **Export car for multiplayer…** (resource editor) - the three + car jobs described under the MCP server below, starting from the car on the stage. + ### MCP server An MCP endpoint runs for the lifetime of the application at `http://127.0.0.1:2010/mcp` - loopback @@ -240,7 +272,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 76 tools. The file tools all read through the same format layer the editor uses, so what +It serves 81 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. @@ -254,9 +286,9 @@ map editor itself. | **Tables** | `list_tables`, `dump_rows`, `lookup_by_row` | | **Stream map** | `parse_stream_map`, `edit_stream_map` | | **Effects** | `parse_effects_file`, `parse_effects_from_bytes` | -| **Utility** | `hash_fnv32`, `hash_fnv64`, `hash_batch`, `convert_number`, `detect_file_format`, `detect_format_from_bytes`, `list_game_files`, `get_configured_games` | +| **Utility** | `hash_fnv32`, `hash_fnv64`, `hash_batch`, `convert_number`, `detect_file_format`, `detect_format_from_bytes`, `list_game_files`, `get_configured_games`, `ping` | | **Editor** | `editor_status`, `editor_list_areas`, `editor_open_area`, `editor_save`, `editor_build`, `editor_mirror_winter`, `editor_undo`, `editor_redo`, `editor_notices` | -| **Scene** | `scene_find`, `scene_select`, `scene_delete_selected`, `scene_duplicate_selected`, `object_move`, `object_properties`, `object_set_property`, `actor_import`, `object_import` | +| **Scene** | `scene_find`, `scene_select`, `scene_delete_selected`, `scene_duplicate_selected`, `object_move`, `object_properties`, `object_set_property`, `actor_import`, `object_import`, `mesh_hide_triangles`, `mesh_materials`, `collision_unused_hulls`, `crash_placements` | | **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` | @@ -271,6 +303,14 @@ and nothing reaches the game before `editor_build` (which keeps the usual timest 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. +`mesh_hide_triangles` cuts an opening into a stock mesh without rebuilding it: the triangles inside +a world-space box are hidden on every level of detail and no vertex is touched, so a stock facade +keeps the channels Blender never sees. It reports by default and hides with `apply`. +`mesh_materials` lists a mesh's material slots and re-points one at another material - no geometry +and no UV is touched. `collision_unused_hulls` counts, and with `apply` removes, the hulls no placement references. +`crash_placements` lists the crash-layer props (trees, lamps, bins) standing in a box and can +delete them, the twin season included. + **Cars.** `car_clone` makes a new car out of an existing one for single player: a copy of its archive (and the winter `_z` twin) with the root frame, name table, prefab entry, entity data and geometry buffers filed under the new model name, registered in the vehicle, paint, cover-point and diff --git a/src/Illusion.Assets/Adapters/FrameNodeAdapter.cs b/src/Illusion.Assets/Adapters/FrameNodeAdapter.cs index 2897399..0380fbe 100644 --- a/src/Illusion.Assets/Adapters/FrameNodeAdapter.cs +++ b/src/Illusion.Assets/Adapters/FrameNodeAdapter.cs @@ -150,6 +150,29 @@ public bool SetSlotMaterial(int slotIndex, ulong hash) return true; } + public IReadOnlyList GetSlotTable() + { + if (Lod0Structs() == null || _frame is not FrameObjectSingleMesh mesh) return []; + return [.. mesh.Material.Materials.Select(level => (level ?? []).Select(slot => slot.MaterialHash).ToArray())]; + } + + public bool SetSlotTable(IReadOnlyList table) + { + ArgumentNullException.ThrowIfNull(table); + if (Lod0Structs() == null || _frame is not FrameObjectSingleMesh mesh) return false; + List levels = mesh.Material.Materials; + if (levels.Count != table.Count) return false; + for (int lod = 0; lod < levels.Count; lod++) + { + if ((levels[lod]?.Length ?? 0) != table[lod].Length) return false; + } + for (int lod = 0; lod < levels.Count; lod++) + { + for (int slot = 0; slot < table[lod].Length; slot++) levels[lod][slot].MaterialHash = table[lod][slot]; + } + return true; + } + // The LOD0 material table, with the same construct-as-side-effect guard GetMaterials uses. private MaterialStruct[]? Lod0Structs() { diff --git a/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs b/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs index 8de4132..ab9aca6 100644 --- a/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs +++ b/src/Illusion.Assets/Adapters/SceneDocumentAdapter.cs @@ -398,6 +398,48 @@ public IReadOnlyList GeometrySharers(FrameObjectSingleMes return sharers; } + /// + /// The other frame objects that draw from one of the given index buffers through a geometry block of their + /// OWN - which does not see. No shipped district has one (measured: 0 of 1310 + /// buffers in uppertown); an object imported twice with a level's draw distance changed in between does. + /// + public IReadOnlyList IndexBufferSharers(FrameObjectSingleMesh mesh, IReadOnlySet indexBuffers) + { + ArgumentNullException.ThrowIfNull(mesh); + ArgumentNullException.ThrowIfNull(indexBuffers); + var sharers = new List(); + foreach (object? value in _frame.FrameObjects.Values) + { + if (value is not FrameObjectSingleMesh other || ReferenceEquals(other, mesh) + || other.Geometry is not { LOD: { } levels } || ReferenceEquals(other.Geometry, mesh.Geometry)) + { + continue; + } + for (int lod = 0; lod < levels.Length; lod++) + { + if (other.GetIndexBuffer(lod) is { } buffer && indexBuffers.Contains(buffer.Hash)) + { + sharers.Add(other); + break; + } + } + } + return sharers; + } + + /// The other frame objects that draw with the same material block: re-pointing one of its slots + /// re-points it for all of them. Rare in the shipped districts (8 of 3099 mesh frames in uppertown). + public IReadOnlyList MaterialSharers(FrameObjectSingleMesh mesh) + { + ArgumentNullException.ThrowIfNull(mesh); + // Asked through the reference table first: the Material property makes an empty block for a mesh that + // has none, and a question must not leave every such mesh of the scene with one. + if (!mesh.Refs.ContainsKey(FrameEntryRefTypes.Material) || mesh.Material is not { } material) return []; + return [.. _frame.FrameObjects.Values.OfType() + .Where(other => !ReferenceEquals(other, mesh) && other.Refs.ContainsKey(FrameEntryRefTypes.Material) + && ReferenceEquals(other.Material, material))]; + } + /// The canonical adapter for a frame object of this document. public FrameNodeAdapter Node(FrameObjectBase frame) { diff --git a/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs b/src/Illusion.Assets/Bridge/BridgeMeshApplier.cs index d6109c8..b5e2134 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( @@ -1491,62 +1572,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 }; @@ -1558,10 +1721,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); @@ -1583,53 +1746,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); @@ -1644,27 +1841,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; } @@ -1686,24 +1890,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); } /// @@ -1933,6 +2167,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.Assets/Frames/FrameDuplicator.cs b/src/Illusion.Assets/Frames/FrameDuplicator.cs index d15d67c..b63861c 100644 --- a/src/Illusion.Assets/Frames/FrameDuplicator.cs +++ b/src/Illusion.Assets/Frames/FrameDuplicator.cs @@ -213,6 +213,125 @@ bool CloneBuffers() return added; } + /// A mesh frame given a geometry block and buffers of its own, with what undo needs to take them + /// away again. + public sealed class OwnedGeometry + { + internal FrameResource Resource = null!; + internal SceneDocumentAdapter Adapter = null!; + internal FrameObjectSingleMesh Mesh = null!; + internal FrameGeometry Before = null!; + internal FrameGeometry After = null!; + internal List VertexBuffers = new(); + internal List IndexBuffers = new(); + internal Dictionary IndexCopies = new(); + + /// The copy that stands, in the mesh's own block, for an index buffer of the shared one. + public IndexBuffer? CopyOf(IndexBuffer shared) => shared == null ? null : IndexCopies.GetValueOrDefault(shared.Hash); + + /// Puts the mesh back on the block it shared (undo). + public void Revert() + { + Mesh.Geometry = Before; + Mesh.ReplaceRef(FrameEntryRefTypes.Geometry, Before.RefID); + Resource.FrameGeometries.Remove(After.RefID); + foreach (VertexBuffer vb in VertexBuffers) Resource.VertexBuffers.Remove(vb.Hash); + foreach (IndexBuffer ib in IndexBuffers) Resource.IndexBuffers.Remove(ib.Hash); + } + + /// Puts the mesh on its own block again (redo). Re-registers what a save-time sanitize may + /// have pruned while it was away. + public void Apply() + { + if (!Resource.FrameGeometries.ContainsKey(After.RefID)) Resource.FrameGeometries.Add(After.RefID, After); + foreach (VertexBuffer vb in VertexBuffers) + { + Resource.VertexBuffers.TryAddToPool(vb); + Adapter.MarkVertexBufferDirty(vb.Hash); + } + foreach (IndexBuffer ib in IndexBuffers) + { + Resource.IndexBuffers.TryAddToPool(ib); + Adapter.MarkIndexBufferDirty(ib.Hash); + } + Mesh.Geometry = After; + Mesh.ReplaceRef(FrameEntryRefTypes.Geometry, After.RefID); + } + } + + /// + /// Gives a mesh frame that draws a geometry block other frames draw too a block of its OWN: a copy of the + /// block over copies of its vertex and index buffers - what a duplicate of the object gets, without the + /// duplicate. The frame looks exactly as before; a change to its geometry now changes it alone. Applied + /// when returned. Null with a reason when it cannot be done (missing buffers, full pools). + /// + public static OwnedGeometry? TryOwnGeometry(ISceneDocument document, IFrameNode node, out string? reason) + { + reason = null; + if (document is not SceneDocumentAdapter adapter || node is not FrameNodeAdapter { Frame: FrameObjectSingleMesh mesh }) + { + reason = "only a mesh has geometry to copy"; + return null; + } + FrameResource resource = adapter.Frame; + if (mesh.Geometry?.LOD is not { Length: > 0 }) + { + reason = "the mesh has no geometry"; + return null; + } + + string unique = Guid.NewGuid().ToString("N")[..8]; + string BufferName(ulong sourceHash, string kind) => $"{mesh.Name.String}_{kind}{sourceHash:x8}_{unique}"; + var owned = new OwnedGeometry { Resource = resource, Adapter = adapter, Mesh = mesh, Before = mesh.Geometry }; + var vertexCopies = new HashSet(); + bool room = true; + foreach (FrameLOD lod in mesh.Geometry.LOD) + { + VertexBuffer? vb = resource.VertexBuffers.GetBuffer(lod.VertexBufferRef.Hash); + IndexBuffer? ib = resource.IndexBuffers.GetBuffer(lod.IndexBufferRef.Hash); + if (vb == null || ib == null) + { + reason = "a level of the mesh has no buffers in this archive"; + room = false; + break; + } + if (vertexCopies.Add(vb.Hash)) + { + var copy = new VertexBuffer(Fnv64.Hash(BufferName(vb.Hash, "vb"))) { Data = (byte[])vb.Data.Clone() }; + if (!resource.VertexBuffers.TryAddToPool(copy)) { room = false; break; } + owned.VertexBuffers.Add(copy); + } + if (!owned.IndexCopies.ContainsKey(ib.Hash)) + { + var copy = new IndexBuffer(Fnv64.Hash(BufferName(ib.Hash, "ib"))); + copy.SetFormat(ib.IndexFormat); + copy.SetData((uint[])ib.GetData().Clone()); + if (!resource.IndexBuffers.TryAddToPool(copy)) { room = false; break; } + owned.IndexCopies[ib.Hash] = copy; + owned.IndexBuffers.Add(copy); + } + } + if (!room) + { + foreach (VertexBuffer vb in owned.VertexBuffers) resource.VertexBuffers.Remove(vb.Hash); + foreach (IndexBuffer ib in owned.IndexBuffers) resource.IndexBuffers.Remove(ib.Hash); + reason ??= "the archive has no buffer pool to copy the geometry into"; + return null; + } + + FrameGeometry geometry = resource.ConstructFrameAssetOfType(); + geometry.CopyFrom(mesh.Geometry); + foreach (FrameLOD lod in geometry.LOD) + { + ulong vertices = lod.VertexBufferRef.Hash, indices = lod.IndexBufferRef.Hash; + lod.VertexBufferRef = new HashName(BufferName(vertices, "vb")); + lod.IndexBufferRef = new HashName(BufferName(indices, "ib")); + } + owned.After = geometry; + owned.Apply(); + return owned; + } + // "_copy", then "_copy2", … — the frame name table is keyed by name, so a copy must not // collide with any existing object. private static string UniqueName(FrameResource resource, string sourceName) diff --git a/src/Illusion.Assets/Sds/TriangleHider.cs b/src/Illusion.Assets/Sds/TriangleHider.cs new file mode 100644 index 0000000..80bb0c4 --- /dev/null +++ b/src/Illusion.Assets/Sds/TriangleHider.cs @@ -0,0 +1,248 @@ +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); + } + + /// A triangle of the mesh's FIRST level of detail - the one drawn up close, and so the one a click + /// lands on - named by where its three indices stand in that level's index buffer. + public sealed record Picked(int Index, float Distance, Triangle Triangle); + + /// + /// The triangle of the first level of detail a world-space ray meets first, from either side, or null when + /// it meets none. A triangle already hidden is not met. + /// + public static Picked? Pick(FrameObjectSingleMesh mesh, Matrix4x4 world, Vector3 origin, Vector3 direction) + { + ArgumentNullException.ThrowIfNull(mesh); + DecodedMesh? decoded = (mesh.Geometry?.LOD?.Length ?? 0) > 0 ? SdsMeshLoader.DecodeLod(mesh, 0) : null; + IndexBuffer? buffer = decoded == null ? null : mesh.GetIndexBuffer(0); + if (decoded == null || buffer == null || mesh.Material?.Materials is not { Count: > 0 } slotsByLod) return null; + + uint[] indices = buffer.GetData(); + Vector3[] positions = decoded.Positions; + Picked? best = null; + foreach (MaterialStruct slot in slotsByLod[0]) + { + 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) || 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 (RayMeets(origin, direction, pa, pb, pc, out float t) && (best == null || t < best.Distance)) + { + best = new Picked(i, t, new Triangle(0, slot.MaterialName ?? "", pa, pb, pc)); + } + } + } + return best; + } + + /// + /// What hiding triangles picked one by one takes out of a mesh: those triangles of the first level of detail + /// (), and on every other level the triangles that LIE ON them - their corners, the + /// middles of their edges and their own middle within metres of a picked triangle. A level cut coarser than the first has + /// triangles that reach past what was picked; those stay, and the opening closes at the distance that level + /// is drawn from - the count per level says so before anything is hidden. + /// + public static Plan FindPicked(FrameObjectSingleMesh mesh, Matrix4x4 world, IReadOnlyCollection picked, float near = 0.05f) + { + ArgumentNullException.ThrowIfNull(mesh); + ArgumentNullException.ThrowIfNull(picked); + var triangles = new List(); + var working = new Dictionary(); + int levels = mesh.Geometry?.LOD?.Length ?? 0; + List? slotsByLod = mesh.Material?.Materials; + var chosen = new List(); + Vector3 lo = new(float.MaxValue), hi = new(float.MinValue); + for (int lod = 0; lod < levels && picked.Count > 0; 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]) + { + int end = Math.Min(slot.StartIndex + (slot.NumFaces * 3), indices.Length); + for (int i = slot.StartIndex; i + 2 < end; i += 3) + { + if (lod == 0 && !picked.Contains(i)) continue; + uint a = indices[i], b = indices[i + 1], c = indices[i + 2]; + if ((a == b && b == c) || 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); + var triangle = new Triangle(lod, slot.MaterialName ?? "", pa, pb, pc); + if (lod == 0) + { + chosen.Add(triangle); + lo = Vector3.Min(lo, Vector3.Min(pa, Vector3.Min(pb, pc))); + hi = Vector3.Max(hi, Vector3.Max(pa, Vector3.Max(pb, pc))); + } + else if (!Covered(pa, pb, pc)) + { + continue; + } + triangles.Add(triangle); + indices[i + 1] = a; + indices[i + 2] = a; + } + } + } + + // Corners on the picks do not make a triangle lie on them: one spanning a door from frame to frame has + // all three on the frame. Its middle and the middles of its edges are asked too. + bool Covered(Vector3 a, Vector3 b, Vector3 c) => + LiesOn(a) && LiesOn(b) && LiesOn(c) && LiesOn((a + b + c) / 3f) && LiesOn((a + b) * 0.5f) && LiesOn((b + c) * 0.5f) && LiesOn((c + a) * 0.5f); + + bool LiesOn(Vector3 p) + { + if (p.X < lo.X - near || p.Y < lo.Y - near || p.Z < lo.Z - near || p.X > hi.X + near || p.Y > hi.Y + near || p.Z > hi.Z + near) return false; + foreach (Triangle t in chosen) + { + if (Vector3.DistanceSquared(p, Closest(p, t.A, t.B, t.C)) <= near * near) return true; + } + return false; + } + + 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); + } + + // Moller-Trumbore, from either side: the editor draws a mesh without culling, so what is seen is what is met. + private static bool RayMeets(Vector3 origin, Vector3 direction, Vector3 a, Vector3 b, Vector3 c, out float t) + { + t = 0f; + Vector3 ab = b - a, ac = c - a; + Vector3 p = Vector3.Cross(direction, ac); + float det = Vector3.Dot(ab, p); + if (MathF.Abs(det) < 1e-12f) return false; + float inv = 1f / det; + Vector3 s = origin - a; + float u = Vector3.Dot(s, p) * inv; + if (u < 0f || u > 1f) return false; + Vector3 q = Vector3.Cross(s, ab); + float v = Vector3.Dot(direction, q) * inv; + if (v < 0f || u + v > 1f) return false; + t = Vector3.Dot(ac, q) * inv; + return t > 1e-4f; + } + + // The point of triangle abc nearest to p (Ericson, Real-Time Collision Detection 5.1.5). + private static Vector3 Closest(Vector3 p, Vector3 a, Vector3 b, Vector3 c) + { + Vector3 ab = b - a, ac = c - a, ap = p - a; + float d1 = Vector3.Dot(ab, ap), d2 = Vector3.Dot(ac, ap); + if (d1 <= 0f && d2 <= 0f) return a; + Vector3 bp = p - b; + float d3 = Vector3.Dot(ab, bp), d4 = Vector3.Dot(ac, bp); + if (d3 >= 0f && d4 <= d3) return b; + float vc = (d1 * d4) - (d3 * d2); + if (vc <= 0f && d1 >= 0f && d3 <= 0f) return a + (ab * (d1 / (d1 - d3))); + Vector3 cp = p - c; + float d5 = Vector3.Dot(ab, cp), d6 = Vector3.Dot(ac, cp); + if (d6 >= 0f && d5 <= d6) return c; + float vb = (d5 * d2) - (d1 * d6); + if (vb <= 0f && d2 >= 0f && d6 <= 0f) return a + (ac * (d2 / (d2 - d6))); + float va = (d3 * d6) - (d5 * d4); + if (va <= 0f && d4 - d3 >= 0f && d5 - d6 >= 0f) return b + ((c - b) * ((d4 - d3) / ((d4 - d3) + (d5 - d6)))); + float denom = 1f / (va + vb + vc); + return a + (ab * (vb * denom)) + (ac * (vc * denom)); + } +} diff --git a/src/Illusion.Assets/Sds/WorkingCopyDiff.cs b/src/Illusion.Assets/Sds/WorkingCopyDiff.cs new file mode 100644 index 0000000..dd9ae67 --- /dev/null +++ b/src/Illusion.Assets/Sds/WorkingCopyDiff.cs @@ -0,0 +1,240 @@ +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using Illusion.Formats.Archive; + +namespace Illusion.Assets.Sds; + +/// How a file of a working copy stands against the archive in the game. +public enum WorkingCopyChangeKind +{ + /// In both, with different contents. + Changed, + /// In the working copy only: a Build adds it to the archive. + Added, + /// In the archive only: a Build drops it from the archive. + Removed, +} + +/// One file a Build would make different in the game. +/// Relative to the working copy, with forward slashes. +/// The working copy's file; for a removed one, the archive's. +/// When the working copy's file was last written (for a removed one, the default). +public sealed record WorkingCopyChange(string Path, WorkingCopyChangeKind Kind, long Size, DateTime Modified); + +/// What packing a working copy would change in the game. +/// The archive in the game. +/// False when the game has no such archive yet - then every file is an addition. +/// The files that differ, by path. +/// Files the working copy's manifest names that are not there: a Build leaves them +/// out of the archive and strikes them from the manifest. +public sealed record WorkingCopyComparison( + FileInfo Archive, bool InGame, IReadOnlyList Changes, IReadOnlyList MissingEntries); + +/// +/// Compares an archive's working copy with the archive as it stands in the game, file by file. +/// +/// A Build packs the WHOLE working copy, not what was edited since it was last opened: a file changed months +/// ago and forgotten goes into the game with today's edit, and nothing said so (it happened with +/// city_univers - an old collision file and name table rode in beside an eight-byte change). This is what a +/// Build window shows before it packs. +/// +/// +/// The archive is unpacked afresh into a scratch folder and the two folders are compared by what the files +/// hold, not by what they are called (see ). Unpacking is deterministic, so a working copy +/// nobody touched compares equal; the game's archive is only read. +/// +/// +public static partial class WorkingCopyDiff +{ + /// Compares the working copy the toolkit keeps for . + /// The archive has no working copy. + public static WorkingCopyComparison Compare(FileInfo sds) + { + ArgumentNullException.ThrowIfNull(sds); + return Compare(sds, MafiaEnvironment.ExtractedDir(sds)); + } + + /// Compares the working copy in with . + /// The folder is not a working copy (it has no manifest). + public static WorkingCopyComparison Compare(FileInfo sds, string workingDir) + { + ArgumentNullException.ThrowIfNull(sds); + ArgumentNullException.ThrowIfNull(workingDir); + if (!File.Exists(Path.Combine(workingDir, "SDSContent.xml"))) + { + throw new FileNotFoundException($"{sds.Name} has no working copy - there is nothing to pack.", Path.Combine(workingDir, "SDSContent.xml")); + } + + IReadOnlyList missing = SdsWriter.MissingEntries(workingDir); + Dictionary mine = Files(workingDir); + // A file the manifest does not name is not packed, whatever lies in the folder (a note, a copy kept + // "just in case"): it is not something a Build puts into the game, and is not listed as one. + // unescaped: the list writes "&" as "&", and a file name is compared as it stands on disk + string manifest = System.Net.WebUtility.HtmlDecode(File.ReadAllText(Path.Combine(workingDir, "SDSContent.xml"))); + // A name that several files of the working copy carry, each in a folder of its own, is told apart by the + // folder: with "/a/foo" unsaid and "/b/foo" still listed, the name alone found it listed. + HashSet shared = [.. mine.Keys.GroupBy(Path.GetFileName, StringComparer.OrdinalIgnoreCase).Where(g => g.Count() > 1).Select(g => g.Key!)]; + // As a whole name - the text of an element, or the end of a path in one - and not as a piece of a longer + // one: with "a.dds" unsaid and "ba.dds" still listed, a search for the letters found it listed. + bool Says(string name) + { + for (int at = manifest.IndexOf(name, StringComparison.OrdinalIgnoreCase); at >= 0; + at = manifest.IndexOf(name, at + 1, StringComparison.OrdinalIgnoreCase)) + { + char before = at == 0 ? '>' : manifest[at - 1]; + int end = at + name.Length; + if (before is '>' or '/' or '\\' && (end >= manifest.Length || manifest[end] == '<')) return true; + } + return false; + } + bool Named(string path) + { + // By its place in the working copy when the name is not its alone, by the name otherwise. A texture's + // top level ("MIP_") is a resource with an entry of its own and is looked up as any other: it + // used to pass as named whenever its texture was, which hid the deletion of the Mipmap alone. + string name = shared.Contains(Path.GetFileName(path)) ? path : Path.GetFileName(path); + return path.Equals("SDSContent.xml", StringComparison.OrdinalIgnoreCase) + || Says(name) || Says(name.Replace('/', '\\')) + // an XML resource is named without the extension it is unpacked with + || (name.EndsWith(".xml", StringComparison.OrdinalIgnoreCase) && (Says(name[..^4]) || Says(name[..^4].Replace('/', '\\')))); + } + sds.Refresh(); + if (!sds.Exists) + { + return new WorkingCopyComparison(sds, false, + [.. mine.Where(f => Named(f.Key)).OrderBy(f => f.Key, StringComparer.OrdinalIgnoreCase) + .Select(f => new WorkingCopyChange(f.Key, WorkingCopyChangeKind.Added, f.Value.Length, f.Value.LastWriteTime))], + missing); + } + + string scratch = Path.Combine(Path.GetTempPath(), "illusion_compare_" + Guid.NewGuid().ToString("N")); + try + { + SdsArchive.Open(sds.FullName).Extract(scratch); + return new WorkingCopyComparison(sds, true, Match(mine, Files(scratch), Named), missing); + } + finally + { + try + { + if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + // a scratch folder left in %TEMP% is not worth failing a comparison over + } + } + } + + // "Actors_306.act", "AnimalTrafficPaths317.atp", "ItemDesc_096a92487af8f55f.ids": a file named by the toolkit, + // after its place in the archive or after a hash, because the archive gives the resource no name. + [GeneratedRegex(@"^[A-Za-z_]+?_?(\d+|[0-9a-f]{16})\.[^./]+$")] + private static partial Regex Unnamed(); + + // Pairs the working copy's files with the archive's. By name alone that goes wrong: a resource the archive + // does not name is unpacked as "_", and the index is its place in the archive - add one + // texture and every such file after it comes out of the rebuilt archive under a new number; and a resource + // the toolkit imported sits in the working copy as "_" and comes back as "_". A + // working copy that was just packed then "differs" from its own archive in dozens of files. So: + // 1. same name, same bytes - the same resource, nothing to say; + // 2. same bytes, same kind, another name - the same resource under another number: nothing to say; + // 3. same name, other bytes - changed; + // 4. unnamed files of one kind left over on both sides - the same resources changed AND renumbered: + // paired in order, as changed; + // 5. what is left: in the working copy only - added; in the archive only - removed. + // "Kind" is the extension, for a file at the top of the folder that is not a texture: a texture keeps the + // name its artist gave it, and so does anything in a folder of its own. + private static List Match(Dictionary mine, Dictionary theirs, Func named) + { + // the manifest is the list of the files, not one of them: an entry added or dropped shows as its file + mine.Remove("SDSContent.xml"); + theirs.Remove("SDSContent.xml"); + // A file the manifest does not name is not in the working copy as far as a Build goes, even when it lies + // there byte for byte as the archive has it: that is what deleting a resource leaves (the entry is + // unsaid, the payload stays). Left in, it was paired with the archive's copy below as "the same" and + // the deletion was not listed - with nothing else changed, the archive came up as "nothing differs". + foreach (string path in mine.Keys.Where(path => !named(path)).ToList()) mine.Remove(path); + var hashes = new Dictionary(StringComparer.OrdinalIgnoreCase); + string Hash(FileInfo file) + { + if (!hashes.TryGetValue(file.FullName, out string? hash)) + { + using FileStream stream = file.OpenRead(); + hashes[file.FullName] = hash = file.Length + ":" + Convert.ToHexString(SHA256.HashData(stream)); + } + return hash; + } + static string Kind(string path) => + !path.Contains('/') && !path.EndsWith(".dds", StringComparison.OrdinalIgnoreCase) ? "*" + Path.GetExtension(path).ToLowerInvariant() : path; + + foreach (string path in mine.Keys.Where(theirs.ContainsKey).ToList()) // 1 + { + if (mine[path].Length == theirs[path].Length && Hash(mine[path]) == Hash(theirs[path])) + { + mine.Remove(path); + theirs.Remove(path); + } + } + var byBytes = new Dictionary>(); // 2 + foreach ((string path, FileInfo file) in theirs.OrderBy(f => f.Key, StringComparer.OrdinalIgnoreCase)) + { + if (Kind(path) == path) continue; + string key = Kind(path) + "|" + Hash(file); + if (!byBytes.TryGetValue(key, out Queue? queue)) byBytes[key] = queue = new Queue(); + queue.Enqueue(path); + } + foreach (string path in mine.Keys.OrderBy(k => k, StringComparer.OrdinalIgnoreCase).ToList()) + { + if (Kind(path) == path) continue; + if (byBytes.TryGetValue(Kind(path) + "|" + Hash(mine[path]), out Queue? queue) && queue.Count > 0) + { + theirs.Remove(queue.Dequeue()); + mine.Remove(path); + } + } + + var changes = new List(); + void Changed(string path) => changes.Add(new WorkingCopyChange(path, WorkingCopyChangeKind.Changed, mine[path].Length, mine[path].LastWriteTime)); + foreach (string path in mine.Keys.Where(theirs.ContainsKey).ToList()) // 3 + { + Changed(path); + mine.Remove(path); + theirs.Remove(path); + } + foreach (IGrouping kind in mine.Keys.Where(k => Kind(k) != k && Unnamed().IsMatch(k)).GroupBy(Kind).ToList()) // 4 + { + var stock = new Queue(theirs.Keys.Where(k => Kind(k) == kind.Key && Unnamed().IsMatch(k)).OrderBy(k => k, StringComparer.OrdinalIgnoreCase)); + foreach (string path in kind.OrderBy(k => k, StringComparer.OrdinalIgnoreCase)) + { + if (stock.Count == 0) break; + Changed(path); + mine.Remove(path); + theirs.Remove(stock.Dequeue()); + } + } + foreach ((string path, FileInfo file) in mine) // 5 + { + changes.Add(new WorkingCopyChange(path, WorkingCopyChangeKind.Added, file.Length, file.LastWriteTime)); + } + foreach ((string path, FileInfo file) in theirs) + { + changes.Add(new WorkingCopyChange(path, WorkingCopyChangeKind.Removed, file.Length, default)); + } + changes.Sort((x, y) => StringComparer.OrdinalIgnoreCase.Compare(x.Path, y.Path)); + return changes; + } + + // Every file of a folder by its path inside it. The toolkit's own note of the archive's memory figures is + // not part of the archive's contents and is left out. + private static Dictionary Files(string root) + { + var files = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (string path in Directory.EnumerateFiles(root, "*", SearchOption.AllDirectories)) + { + string relative = Path.GetRelativePath(root, path).Replace('\\', '/'); + if (string.Equals(relative, SdsMemoryRequirements.FileName, StringComparison.OrdinalIgnoreCase)) continue; + files[relative] = new FileInfo(path); + } + return files; + } +} diff --git a/src/Illusion.Domain/Materials/IMaterialSlotEditor.cs b/src/Illusion.Domain/Materials/IMaterialSlotEditor.cs index e803e89..65b08e4 100644 --- a/src/Illusion.Domain/Materials/IMaterialSlotEditor.cs +++ b/src/Illusion.Domain/Materials/IMaterialSlotEditor.cs @@ -13,4 +13,14 @@ public interface IMaterialSlotEditor : ISceneSource /// Repoints a slot at another material hash. False when the slot is out of range. bool SetSlotMaterial(int slotIndex, ulong hash); + + /// + /// The hash every slot of every level binds, level by level. What an undo of a re-point puts back: the + /// mirror into further levels goes by the slot's OLD hash, and that cannot be run backwards - a level whose + /// slots were A and B is B and B after "A to B", and "B to A" makes it A and A. + /// + IReadOnlyList GetSlotTable(); + + /// Puts a table taken with back. False when it no longer fits the mesh. + bool SetSlotTable(IReadOnlyList table); } 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.Formats/StreamMap/StreamMapEditor.cs b/src/Illusion.Formats/StreamMap/StreamMapEditor.cs index 0c3bcc7..20a114e 100644 --- a/src/Illusion.Formats/StreamMap/StreamMapEditor.cs +++ b/src/Illusion.Formats/StreamMap/StreamMapEditor.cs @@ -89,6 +89,36 @@ public static class StreamMapEditor /// private static readonly Encoding PoolEncoding = Encoding.Latin1; + /// + /// Writes a patched StreamMap over , after putting what stood there beside it + /// as a backup, and returns the backup's path. The backup goes down first. + /// + /// It never overwrites an earlier one. The name is derived from the target, so it is the same on every + /// edit: a second pass over the same file would have replaced the pristine backup with the + /// already-patched file, leaving two copies of modified data and no way back. The first backup is the + /// valuable one, so it is kept as <name>_old.bin and later passes get _old.2.bin, + /// _old.3.bin and so on. + /// + /// + public static string WriteWithBackup(string filePath, byte[] original, byte[] patched) + { + ArgumentNullException.ThrowIfNull(filePath); + ArgumentNullException.ThrowIfNull(original); + ArgumentNullException.ThrowIfNull(patched); + string directory = Path.GetDirectoryName(filePath) ?? "."; + string stem = Path.GetFileNameWithoutExtension(filePath) + "_old"; + string extension = Path.GetExtension(filePath); + + string backup = Path.Combine(directory, stem + extension); + for (int n = 2; File.Exists(backup); n++) + { + backup = Path.Combine(directory, stem + "." + n.ToString(System.Globalization.CultureInfo.InvariantCulture) + extension); + } + File.WriteAllBytes(backup, original); + File.WriteAllBytes(filePath, patched); + return backup; + } + /// /// Replaces with in every selected string /// field. Returns what it found and, unless , the patched bytes. diff --git a/src/Illusion.Mcp/EditorModels.cs b/src/Illusion.Mcp/EditorModels.cs index f43e622..5377891 100644 --- a/src/Illusion.Mcp/EditorModels.cs +++ b/src/Illusion.Mcp/EditorModels.cs @@ -46,6 +46,27 @@ public sealed record CarCloneInfo(string Name, int VehicleId, int TrafficRows, i /// not in the archive, and no longer in the manifest. public sealed record PackedArchive(string Archive, string? Backup, IReadOnlyList? Dropped = null); +/// 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 material slot of a mesh: its index, the material it draws with (null when no loaded library +/// knows the hash), how many triangles it covers, and whether this call re-pointed it. +public sealed record MeshSlotInfo(int Slot, string? Material, int Triangles, bool Changed); + +/// One collision file of the open scene: its placements, the hulls it carried, how many of those no +/// placement referenced, and whether they were removed or only counted. +public sealed record UnusedHullsInfo(string Layer, int Placements, int Hulls, int Unused, bool Removed); + +/// One placement of a city_crash prop (a tree, a lamp, a bin): the prop's name, the placement's id in the +/// table, where it stands, whether the other season holds the same placement, and whether the two are kept in +/// step — an edit of a linked placement is made to its twin, and deleting it deletes both; one the user +/// unlinked ("In all seasons" off) loses only the season on screen. +public sealed record CrashPlacementInfo(string Prop, int Id, float[] Position, bool BothSeasons, bool SeasonLinked); + /// 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 031cb89..d5bcdb2 100644 --- a/src/Illusion.Mcp/IEditorSession.cs +++ b/src/Illusion.Mcp/IEditorSession.cs @@ -160,6 +160,30 @@ 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. + /// says what to do when other objects draw the same geometry: null refuses, "own" gives the mesh a copy of + /// its own first, "all" hides the triangles on every one of them. Null on success. + string? HideTriangles(string name, float[] boxMin, float[] boxMax, string? material, bool apply, int sample, string? shared, + out HiddenTrianglesInfo? result); + + /// The material slots of a mesh; with and , that + /// slot is first re-pointed at the named material, as one undoable edit. No geometry and no UV is touched. + /// Null on success. + string? MeshMaterials(string name, int? slot, string? material, out IReadOnlyList slots); + + /// Counts — and with removes, as one undoable edit — the hulls no placement + /// references, in every collision file of the open scene. Null on success. + string? UnusedHulls(bool apply, out IReadOnlyList result); + + /// Lists — and with removes, as one undoable edit and in both seasons where + /// the placement is linked — the city_crash props standing inside a world-space box. + /// is how many the box holds; the list stops at — except for a delete, which lists + /// every placement it removed. A delete of more than placements is refused + /// whole. Null on success. + string? CrashPlacements(float[] boxMin, float[] boxMax, string? nameContains, bool delete, int limit, int maxDelete, + out IReadOnlyList result, out int total); + string? Undo(); string? Redo(); diff --git a/src/Illusion.Mcp/Tools/EditorTools.cs b/src/Illusion.Mcp/Tools/EditorTools.cs index 48bb6fe..3cb5578 100644 --- a/src/Illusion.Mcp/Tools/EditorTools.cs +++ b/src/Illusion.Mcp/Tools/EditorTools.cs @@ -225,6 +225,110 @@ 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. A mesh that shares its geometry with other objects is refused unless 'shared' says what to do about them.")] + 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, + [Description("What to do when other objects draw the same geometry (the districts reuse it heavily): omit to refuse, 'own' gives this mesh a copy of the geometry of its own first and cuts it alone, 'all' hides the triangles on every object that draws it.")] string? shared = null) + { + try + { + HiddenTrianglesInfo? result = null; + string? refused = await ui.RunAsync(() => editor.HideTriangles(name, boxMin, boxMax, material, apply, sample, shared, out result)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, hidden = result }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "mesh_materials")] + [Description("The material slots of a mesh - which material each part of it is drawn with, and how many triangles that part has. With 'slot' and 'material', that slot is first re-pointed at another material (what the Materials tab's 'assign to slot' does): one undoable edit that touches no geometry and no UV, so the new texture is laid out the way the old one was - look at the result. Further levels of detail follow wherever they used the slot's old material. The material is named exactly as search_materials spells it. A district's winter archive is edited separately. The hull's surface type (what footsteps sound like, where grass tufts grow) is the collision's own and does not change with this.")] + public static async Task MeshMaterials( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("The mesh: its name, or its path when the name is not unique (scene_find gives both).")] string name, + [Description("Slot to re-point. Omit, with 'material', to only list the slots.")] int? slot = null, + [Description("Material to point the slot at, by exact name.")] string? material = null) + { + try + { + IReadOnlyList slots = []; + string? refused = await ui.RunAsync(() => editor.MeshMaterials(name, slot, material, out slots)); + return refused != null ? ToolResult.Invalid(refused) : ToolResult.Json(new { success = true, changed = slots.Any(s => s.Changed), slots }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "collision_unused_hulls")] + [Description("Count the collision hulls no placement references — dead weight a delete, a re-cook or a resize leaves behind in the .col (deleting a placement never removes its hull, so undo can put the file back). For every collision file of the open scene: its placements, its hulls and how many of those are unused. By default it only REPORTS; pass apply=true to remove them, all files as one undoable edit. Placements are never touched, so nothing changes in the game except the archive's size. Saved by editor_save, packed by editor_build; run editor_mirror_winter afterwards so the winter district loses them too.")] + public static async Task CollisionUnusedHulls( + IEditorSession editor, + IUiThreadMarshal ui, + [Description("Remove the unused hulls. Default false: count only.")] bool apply = false) + { + try + { + IReadOnlyList layers = []; + string? refused = await ui.RunAsync(() => editor.UnusedHulls(apply, out layers)); + return refused != null + ? ToolResult.Invalid(refused) + : ToolResult.Json(new { success = true, unused = layers.Sum(l => l.Unused), removed = apply, layers }); + } + catch (Exception ex) + { + return ToolResult.Fail(ex); + } + } + + [McpServerTool(Name = "crash_placements")] + [Description("The city_crash props — trees, bushes, lamps, bins, mailboxes — standing inside a world-space box: each one's prop name, placement id, position and whether the other season holds the same placement. scene_find only shows the prop TYPES of that layer ('lampLuxus — 31'); this is how to see where the copies stand. The layer must be in the scene (view_set crash=true). By default it only LISTS; pass delete=true to remove every placement the box (and the name filter) takes, as one undoable edit — a placement linked to the other season is removed there too. city_crash is ONE archive for the whole city: keep the box tight. Saved by editor_save, packed by editor_build.")] + public static async Task CrashPlacements( + IEditorSession editor, + IUiThreadMarshal ui, + [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 props whose name contains this. Omit for every prop.")] string? nameContains = null, + [Description("Delete the placements found. Default false: list only.")] bool delete = false, + [Description("How many placements to list (the count is always complete; a delete lists everything it removed). Default 200.")] int limit = 200, + [Description("The most placements one delete may remove. With more than this in the box the call is refused and nothing is deleted. Default 100.")] int maxDelete = 100) + { + try + { + IReadOnlyList placements = []; + int total = 0; + string? refused = await ui.RunAsync(() => + editor.CrashPlacements(boxMin, boxMax, nameContains, delete, limit, maxDelete, out placements, out total)); + return refused != null + ? ToolResult.Invalid(refused) + : ToolResult.Json(new + { + success = true, + count = total, + deleted = delete ? total : 0, + // Gone from the OTHER season as well: only the linked ones. An unlinked placement that + // has a twin keeps it. + deletedInOtherSeasonToo = delete ? placements.Count(p => p.BothSeasons && p.SeasonLinked) : 0, + placements, + }); + } + 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( @@ -332,7 +436,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. 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.")] + [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. 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/Tools/StreamMapTools.cs b/src/Illusion.Mcp/Tools/StreamMapTools.cs index f215cee..27e5080 100644 --- a/src/Illusion.Mcp/Tools/StreamMapTools.cs +++ b/src/Illusion.Mcp/Tools/StreamMapTools.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using System.Globalization; using Illusion.Formats.StreamMap; using ModelContextProtocol.Server; @@ -154,17 +153,9 @@ public static string EditStreamMap( bool written = false; if (!dryRun && patch.Patched is not null && applied > 0) { - // The backup goes down before the file does, and only when there is something to - // write — a no-op edit should not churn a backup the user may still need. - // - // It must also never overwrite an existing one. The name is derived from the target, - // so it is the same on every edit: a second pass over the same StreamMap would have - // replaced the pristine backup with the ALREADY-PATCHED file, leaving two copies of - // modified data and no way back to the original. The first backup is the valuable - // one, so it is kept and later passes get a numbered sibling instead. - backup = NextBackupPath(filePath); - File.WriteAllBytes(backup, original); - File.WriteAllBytes(filePath, patch.Patched); + // Only when there is something to write — a no-op edit should not churn a backup the user + // may still need. The write keeps the first backup and numbers the later ones. + backup = StreamMapEditor.WriteWithBackup(filePath, original, patch.Patched); written = true; } @@ -198,28 +189,6 @@ public static string EditStreamMap( } } - /// - /// A backup path that does not already exist: <name>_old.bin for the first edit, - /// then _old.2.bin, _old.3.bin and so on. Never returns a path that would clobber - /// an earlier backup — the whole point of the file is to still hold the original after the - /// second edit. - /// - private static string NextBackupPath(string filePath) - { - string directory = Path.GetDirectoryName(filePath) ?? "."; - string stem = Path.GetFileNameWithoutExtension(filePath) + "_old"; - string extension = Path.GetExtension(filePath); - - string candidate = Path.Combine(directory, stem + extension); - for (int n = 2; File.Exists(candidate); n++) - { - candidate = Path.Combine( - directory, - stem + "." + n.ToString(CultureInfo.InvariantCulture) + extension); - } - return candidate; - } - /// Parses the comma-separated field selector. An unknown name is refused rather than /// ignored — a typo that silently widened the edit to every field would be the worst outcome /// here, since this tool writes. diff --git a/src/Illusion.Rendering/Controls/ViewportControl.cs b/src/Illusion.Rendering/Controls/ViewportControl.cs index f692b66..492195f 100644 --- a/src/Illusion.Rendering/Controls/ViewportControl.cs +++ b/src/Illusion.Rendering/Controls/ViewportControl.cs @@ -341,11 +341,20 @@ private void OnRendering(object? sender, EventArgs e) UpdateCamera(dt); CameraMoved?.Invoke(); + // A hook above can pump the dispatcher (a dialog, a wait for a load), and a frame run inside that pump + // may have replaced the surface - at another size, the old one disposed. This frame's target is then + // gone: nothing is drawn into it, the next frame draws into the new one. + if (!ReferenceEquals(_surface.Target, target)) return; + _image.Lock(); try { Renderer.Render(target); - _image.AddDirtyRect(new Int32Rect(0, 0, target.Width, target.Height)); + // Never more than the image holds: a dirty rectangle larger than its back buffer is an exception + // out of the render pass, with nothing above it but the end of the program - seen when the window + // was resized while a district loaded ("dirtyRect 1194 must be less than or equal to 232"). + int width = Math.Min(target.Width, _image.PixelWidth), height = Math.Min(target.Height, _image.PixelHeight); + if (width > 0 && height > 0) _image.AddDirtyRect(new Int32Rect(0, 0, width, height)); } finally { diff --git a/src/Illusion.Rendering/Passes/SceneRenderer.cs b/src/Illusion.Rendering/Passes/SceneRenderer.cs index 48439ea..b0fe200 100644 --- a/src/Illusion.Rendering/Passes/SceneRenderer.cs +++ b/src/Illusion.Rendering/Passes/SceneRenderer.cs @@ -31,6 +31,7 @@ public sealed unsafe class SceneRenderer : IDisposable private readonly HelperGlyphRenderer _helperHighlightRenderer; private readonly LineOverlayRenderer _partShapeRenderer; private readonly LineOverlayRenderer _hitBoxRenderer; + private readonly LineOverlayRenderer _pickedRenderer; private readonly ActorMarkerRenderer _actorRenderer; private readonly ActorMarkerRenderer _actorSelectionRenderer; private readonly SelectionOutlineRenderer _selectionOutline; @@ -166,6 +167,16 @@ public void SetHitBoxLines(IReadOnlyList lineVertices, IReadOnlyListDrops the hit-box wireframe — what a scene reset owes this layer. public void ClearHitBoxes() => _hitBoxRenderer.Clear(); + /// + /// Replaces the outline of the triangles picked for a tool (Tools → Hide triangles marks the ones a click + /// chose). Drawn whenever there are any - it is not a layer to switch, it is the tool's own selection - and + /// gone with an empty list. + /// + public void SetPickedTriangleLines(IReadOnlyList lineVertices, IReadOnlyList colors) => + _pickedRenderer.SetDistrict(PickedKey, lineVertices, colors); + + private static readonly object PickedKey = new(); + private static readonly object HitBoxKey = new(); /// Removes one archive's rig (district unload). @@ -299,6 +310,7 @@ public SceneRenderer(GpuContext gpu) _helperHighlightRenderer = new HelperGlyphRenderer(_linePass); _partShapeRenderer = new LineOverlayRenderer(_linePass); _hitBoxRenderer = new LineOverlayRenderer(_linePass); + _pickedRenderer = new LineOverlayRenderer(_linePass); _actorRenderer = new ActorMarkerRenderer(_linePass); _actorSelectionRenderer = new ActorMarkerRenderer(_linePass); _selectionOutline = new SelectionOutlineRenderer(gpu); @@ -597,6 +609,9 @@ public void Render(SharedRenderTarget target) _hitBoxRenderer.Render(ctx, overlayFrame, RigStyle(new Vector4(1f, 1f, 1f, 0.55f), 1.2f)); } + // The triangles a tool has picked, in their own colours. + if (_pickedRenderer.HasData) _pickedRenderer.Render(ctx, overlayFrame, RigStyle(Vector4.One, 2.2f)); + // Actor glyphs: everything the .act pack places that has no geometry of its own, coloured per segment // (per category), so the tint stays neutral. if (ShowActors) _actorRenderer.Render(ctx, overlayFrame, OverlayLineStyle.Default(Vector4.One)); @@ -903,6 +918,7 @@ public void Dispose() _helperHighlightRenderer.Dispose(); _partShapeRenderer.Dispose(); _hitBoxRenderer.Dispose(); + _pickedRenderer.Dispose(); _navMeshRenderer.Dispose(); _navRenderer.Dispose(); _linePass.Dispose(); 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/Bridge/BridgeSessionController.cs b/src/Illusion/Bridge/BridgeSessionController.cs index cbda9c3..737efc7 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); @@ -1043,6 +1084,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/ProbeRunner.cs b/src/Illusion/Diagnostics/ProbeRunner.cs index 653a3fd..48cbfee 100644 --- a/src/Illusion/Diagnostics/ProbeRunner.cs +++ b/src/Illusion/Diagnostics/ProbeRunner.cs @@ -39,6 +39,12 @@ public static bool TryRun(string[] args) case "--probe-stream": WorldProbes.RunStreamProbe(); return true; + // What a Build would put into the game: an untouched working copy shows nothing, a changed and a + // removed file are listed, a loose file is not. Arguments: archives under pc\sds (default: + // city_univers and one district). Reads the game only. Output: %TEMP%\illusion_build_list.txt + case "--probe-build-list": + BuildListProbes.Run(args.Skip(1).ToArray()); + return true; // Dump of district scenes + their categories (proxy/snow/normal). case "--probe-scenes": SceneProbes.RunScenesProbe(args.Length >= 2 ? args[1] : "eastside"); @@ -173,6 +179,21 @@ public static bool TryRun(string[] args) case "--probe-prop-catalog": PropCatalogProbes.RunPropCatalogProbe(); return true; + // What a remap pool is, over every skinned model: the pools against the skeleton's own account + // of them. Optional arg = the car to dump. Output: %TEMP%\illusion_remap_pools.txt + case "--probe-remap-pools": + RemapPoolProbes.RunRemapPoolProbe(args.Length >= 2 ? args[1] : "shubert_38"); + return true; + // Where a person's stored geometry and rig stand, against where they are drawn. + // Optional arg = archive under pc\sds. Output: %TEMP%\illusion_person_rig.txt + case "--probe-person-rig": + PersonRigProbes.RunPersonRigProbe(args.Length >= 2 ? args[1] : "traffic/cirand.sds"); + return true; + // The content browser's archive pictures: people (head and shoulders, from both sides) and a car. + // Optional args = archives under pc\sds. Output: %TEMP%\illusion_archive_thumbs.txt + pictures. + case "--probe-archive-thumbs": + ArchiveThumbnailProbes.RunArchiveThumbnailProbe(args.Skip(1).ToArray()); + 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 @@ -193,6 +214,12 @@ public static bool TryRun(string[] args) case "--probe-car-m2o": CarM2oExportProbes.RunCarM2oExportProbe(); return true; + // Hiding triangles of a stock mesh without a rebuild, in memory only: what a box or a click finds, + // that nothing but the found triangles' indices changes, a mesh given geometry of its own. + // Output: %TEMP%\illusion_hide_triangles.txt + case "--probe-hide-triangles": + HideTrianglesProbes.RunHideTrianglesProbe(args.Length >= 2 ? args[1] : "uppertown"); + 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/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/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/BuildListProbes.cs b/src/Illusion/Diagnostics/Probes/BuildListProbes.cs new file mode 100644 index 0000000..a21a872 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/BuildListProbes.cs @@ -0,0 +1,215 @@ +using System.Diagnostics; +using System.IO; +using System.Text; +using Illusion.Assets; +using Illusion.Assets.Sds; +using Illusion.Formats.Archive; +using static Illusion.Diagnostics.Probes.ProbeAssert; + +namespace Illusion.Diagnostics.Probes; + +/// Probes of what a Build would put into the game (). +internal static class BuildListProbes +{ + // The list a Build window shows, against real archives. For each archive named (default: city_univers, a + // small one, and a district) the archive is unpacked into %TEMP% and compared with itself - an untouched + // working copy must show NOTHING, or every Build window would cry wolf. Then one file is changed, one the + // manifest names is removed, one loose file is dropped in beside them and one named piece is added: the + // list must hold exactly the changed one, the removed one (also as a missing manifest entry) and the + // manifest - and not the loose file, which a pack does not take. The game is only read. + // With "real ..." it lists instead what a Build of the toolkit's own working copies would change. + // Output: %TEMP%\illusion_build_list.txt + internal static void Run(string[] args) + { + string outFile = Path.Combine(Path.GetTempPath(), "illusion_build_list.txt"); + var sb = new StringBuilder(); + int pass = 0, fail = 0; + void Check(string label, bool ok, string detail = "") + { + if (ok) pass++; else fail++; + sb.AppendLine($"[{(ok ? "PASS" : "FAIL")}] {label}{(detail.Length > 0 ? " - " + detail : "")}"); + } + var scratches = new List(); + try + { + if (!InitEnv(out string? err)) { sb.AppendLine("INIT FAIL: " + err); return; } + // "real ..." lists, for the working copies the toolkit actually keeps, what a Build of + // each would change in the game - the same list the Build window shows. Nothing is checked. + if (args.Length > 1 && args[0] == "real") + { + foreach (string name in args.Skip(1)) + { + var real = new FileInfo(Path.IsPathRooted(name) ? name : Path.Combine(MafiaEnvironment.PcFolder, "sds", name)); + var watch = Stopwatch.StartNew(); + WorkingCopyComparison found = WorkingCopyDiff.Compare(real); + sb.AppendLine($"{real.Name}: {found.Changes.Count} file(s) differ from the game, {found.MissingEntries.Count} manifest entr(ies) without a file ({watch.ElapsedMilliseconds} ms)"); + foreach (WorkingCopyChange change in found.Changes.Take(40)) + { + sb.AppendLine($" {change.Kind,-8} {change.Path} {change.Size} B {change.Modified:yyyy-MM-dd HH:mm}"); + } + } + return; + } + List archives = args.Length > 0 + ? [.. args.Select(a => new FileInfo(Path.IsPathRooted(a) ? a : Path.Combine(MafiaEnvironment.PcFolder, "sds", a)))] + : [new FileInfo(MafiaEnvironment.CityUniversSds), new FileInfo(Path.Combine(MafiaEnvironment.CityFolder, "arpradelna.sds"))]; + foreach (FileInfo sds in archives) + { + if (!sds.Exists) { Check($"{sds.Name} is in the game", false, sds.FullName); continue; } + string scratch = Path.Combine(Path.GetTempPath(), "illusion_buildlist_probe_" + Guid.NewGuid().ToString("N")); + scratches.Add(scratch); + var clock = Stopwatch.StartNew(); + SdsArchive.Open(sds.FullName).Extract(scratch); + int files = Directory.EnumerateFiles(scratch, "*", SearchOption.AllDirectories).Count(); + long unpack = clock.ElapsedMilliseconds; + + clock.Restart(); + WorkingCopyComparison same = WorkingCopyDiff.Compare(sds, scratch); + Check($"{sds.Name}: a working copy nobody touched shows nothing ({files} files; unpack {unpack} ms, compare {clock.ElapsedMilliseconds} ms)", + same.InGame && same.Changes.Count == 0 && same.MissingEntries.Count == 0, + string.Join(", ", same.Changes.Take(6).Select(c => $"{c.Kind} {c.Path}"))); + + // a resource the archive does not name is unpacked under its index, and the index moves when a + // resource is added before it: the same bytes under another number are the same resource + string? numbered = Directory.EnumerateFiles(scratch).Select(Path.GetFileName) + .FirstOrDefault(n => n != null && System.Text.RegularExpressions.Regex.IsMatch(n, @"^[A-Za-z_]+_\d+\.(fr|fnt|act|col|ibp|vbp)$")); + if (numbered != null) + { + string renumbered = System.Text.RegularExpressions.Regex.Replace(numbered, @"\d+(\.[a-z]+)$", "9999$1"); + File.Move(Path.Combine(scratch, numbered), Path.Combine(scratch, renumbered)); + string manifestPath = Path.Combine(scratch, "SDSContent.xml"); + File.WriteAllText(manifestPath, File.ReadAllText(manifestPath).Replace(">" + numbered + "<", ">" + renumbered + "<")); + WorkingCopyComparison moved = WorkingCopyDiff.Compare(sds, scratch); + Check($"{sds.Name}: {numbered} renumbered to {renumbered} is still the same resource - nothing listed", + moved.Changes.Count == 0 && moved.MissingEntries.Count == 0, string.Join(", ", moved.Changes.Take(6).Select(c => $"{c.Kind} {c.Path}"))); + File.AppendAllText(Path.Combine(scratch, renumbered), "x"); + WorkingCopyComparison both = WorkingCopyDiff.Compare(sds, scratch); + Check($"{sds.Name}: renumbered AND changed, it is listed once, as changed", + both.Changes.Count == 1 && both.Changes[0] is { Kind: WorkingCopyChangeKind.Changed } only && only.Path == renumbered, + string.Join(", ", both.Changes.Take(6).Select(c => $"{c.Kind} {c.Path}"))); + // put back, for the checks below + File.Delete(Path.Combine(scratch, renumbered)); + Directory.Delete(scratch, recursive: true); + SdsArchive.Open(sds.FullName).Extract(scratch); + } + + // one changed, one removed, one loose + List plain = [.. Directory.EnumerateFiles(scratch).Select(Path.GetFileName).Where(n => n != "SDSContent.xml").Select(n => n!) + .OrderBy(n => n, StringComparer.Ordinal)]; + if (plain.Count < 2) { sb.AppendLine($"({sds.Name} has fewer than two files beside its manifest - the change checks are skipped)"); continue; } + string changed = plain[0], removed = plain[^1]; + File.AppendAllText(Path.Combine(scratch, changed), "x"); + File.Delete(Path.Combine(scratch, removed)); + File.WriteAllText(Path.Combine(scratch, "note to self.txt"), "not part of the archive"); + WorkingCopyComparison edited = WorkingCopyDiff.Compare(sds, scratch); + List got = [.. edited.Changes.Select(c => $"{c.Kind} {c.Path}")]; + Check($"{sds.Name}: a changed file and a removed one are listed, and nothing else", + got.SequenceEqual(new[] { $"Changed {changed}", $"Removed {removed}" }.OrderBy(x => x[(x.IndexOf(' ') + 1)..], StringComparer.OrdinalIgnoreCase)), + string.Join(", ", got)); + Check($"{sds.Name}: the removed file is also named as an entry the pack would leave out", + edited.MissingEntries.Any(m => m.TrimStart('/', '\\').Equals(removed, StringComparison.OrdinalIgnoreCase)), string.Join(", ", edited.MissingEntries)); + Check($"{sds.Name}: a loose file the manifest does not name is not listed", + !edited.Changes.Any(c => c.Path.Contains("note to self"))); + Check($"{sds.Name}: the changed file carries its size and the time it was written", + edited.Changes.FirstOrDefault(c => c.Kind == WorkingCopyChangeKind.Changed) is { Size: > 0 } c1 && (DateTime.Now - c1.Modified).TotalMinutes < 5); + + // A resource deleted in the content browser: its entry is unsaid and its payload stays where it + // lay, byte for byte what the archive has. The pack leaves it out, so it is a removal - and was + // once paired with the archive's copy as "the same", which made the deletion invisible. + if (plain.Count >= 3) + { + string unsaid = plain[1]; + string entry = Path.GetExtension(unsaid).Equals(".xml", StringComparison.OrdinalIgnoreCase) ? unsaid[..^4] : unsaid; + var manifest = Formats.Archive.SdsManifest.Load(scratch); + string? listed = manifest.Entries.Select(e => e.File).FirstOrDefault(f => f.TrimStart('/', '\\').Equals(entry, StringComparison.OrdinalIgnoreCase)); + if (listed != null && manifest.RemoveEntry(listed)) + { + WorkingCopyComparison dropped = WorkingCopyDiff.Compare(sds, scratch); + Check($"{sds.Name}: a resource unsaid in the list, its file still lying there, is listed as removed", + dropped.Changes.Any(c => c.Kind == WorkingCopyChangeKind.Removed && c.Path.Equals(unsaid, StringComparison.OrdinalIgnoreCase)) + && dropped.Changes.Count == edited.Changes.Count + 1, string.Join(", ", dropped.Changes.Select(c => $"{c.Kind} {c.Path}"))); + } + else + { + sb.AppendLine($"({sds.Name}: {unsaid} is not a plain entry of the list - the unsaid-resource check is skipped)"); + } + } + + // A texture's top level lies beside it as "MIP_" and is a resource with an entry of its own. + // Unsaid alone - its texture still listed - it used to pass as named by the texture's entry. + { + var manifest = Formats.Archive.SdsManifest.Load(scratch); + string? mip = Directory.EnumerateFiles(scratch).Select(Path.GetFileName).Select(n => n!) + .FirstOrDefault(n => n.StartsWith("MIP_", StringComparison.OrdinalIgnoreCase) && File.Exists(Path.Combine(scratch, n[4..])) + && manifest.HasFile(n) && manifest.HasFile(n[4..])); + if (mip != null) + { + int before = WorkingCopyDiff.Compare(sds, scratch).Changes.Count; + manifest.RemoveEntry(mip); + WorkingCopyComparison without = WorkingCopyDiff.Compare(sds, scratch); + Check($"{sds.Name}: a Mipmap unsaid alone, its texture still listed, is listed as removed", + without.Changes.Count == before + 1 && without.Changes.Any(c => c.Kind == WorkingCopyChangeKind.Removed && c.Path.Equals(mip, StringComparison.OrdinalIgnoreCase)), + $"{mip}: {before} -> {without.Changes.Count} change(s)"); + } + else + { + sb.AppendLine($"({sds.Name} has no texture with a Mipmap of its own entry - the Mipmap check is skipped)"); + } + } + } + + // an archive the game does not have yet: everything the manifest names is an addition + if (scratches.Count > 0) + { + var absent = new FileInfo(Path.Combine(Path.GetTempPath(), "illusion_no_such_archive_" + Guid.NewGuid().ToString("N") + ".sds")); + WorkingCopyComparison fresh = WorkingCopyDiff.Compare(absent, scratches[0]); + Check("an archive not in the game yet: every packed file is an addition", + !fresh.InGame && fresh.Changes.Count > 0 && fresh.Changes.All(c => c.Kind == WorkingCopyChangeKind.Added) + && !fresh.Changes.Any(c => c.Path.Contains("note to self")), $"{fresh.Changes.Count} file(s)"); + } + // The window itself, with a row in it. A wrong style or binding in a row's template is not a build + // error: it throws when the first row is made - and took the application down the first time. + { + var window = new Views.BuildWindow([new FileInfo(MafiaEnvironment.CityUniversSds)]) { ShowInTaskbar = false, Left = -4000, Top = -4000 }; + window.Show(); + var limit = Stopwatch.StartNew(); + while (window.Rows.Any(r => r.Comparing) && limit.ElapsedMilliseconds < 20000) + { + var frame = new System.Windows.Threading.DispatcherFrame(); + System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke(System.Windows.Threading.DispatcherPriority.Background, + new Action(() => frame.Continue = false)); + System.Windows.Threading.Dispatcher.PushFrame(frame); + Thread.Sleep(20); + } + window.UpdateLayout(); + Views.BuildWindow.ArchiveRow row = window.Rows[0]; + Check("the Build window makes a row for an archive and finishes comparing it", !row.Comparing && row.Summary.Length > 0, row.Summary); + row.IsOpen = true; + window.UpdateLayout(); + Check("...and the row can be opened and ticked without the window falling over", window.IsLoaded); + window.Close(); + } + + bool refused = false; + try { WorkingCopyDiff.Compare(new FileInfo(MafiaEnvironment.CityUniversSds), Path.Combine(Path.GetTempPath(), "illusion_no_such_folder")); } + catch (FileNotFoundException) { refused = true; } + Check("a folder that is not a working copy is refused", refused); + Check("nothing is left in %TEMP% by the comparisons themselves", + !Directory.EnumerateDirectories(Path.GetTempPath(), "illusion_compare_*").Any()); + } + catch (Exception ex) + { + fail++; + sb.AppendLine("unexpected exception: " + ex); + } + finally + { + foreach (string scratch in scratches) + { + try { if (Directory.Exists(scratch)) Directory.Delete(scratch, recursive: true); } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) { sb.AppendLine("(scratch left behind: " + scratch + ")"); } + } + File.WriteAllText(outFile, $"BUILD LIST PROBE: {pass} passed, {fail} failed\n" + sb); + } + } +} diff --git a/src/Illusion/Diagnostics/Probes/HideTrianglesProbes.cs b/src/Illusion/Diagnostics/Probes/HideTrianglesProbes.cs new file mode 100644 index 0000000..2dff357 --- /dev/null +++ b/src/Illusion/Diagnostics/Probes/HideTrianglesProbes.cs @@ -0,0 +1,234 @@ +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)); + + // What the district's frames share with one another - which is what an edit of ONE mesh has to refuse + // or carry to the others: a geometry block, a material block, an index buffer under two blocks. + { + List all = [.. scene.FrameResource!.FrameObjects.Values.OfType()]; + int Shared(Func of) => all.Where(m => of(m) != null) + .GroupBy(m => of(m)!, ReferenceEqualityComparer.Instance).Where(g => g.Count() > 1).Sum(g => g.Count()); + int materialAcrossGeometry = all.Where(m => m.Material != null).GroupBy(m => (object)m.Material, ReferenceEqualityComparer.Instance) + .Count(g => g.Select(m => (object?)m.Geometry).Distinct(ReferenceEqualityComparer.Instance).Count() > 1); + var blocksOfBuffer = new Dictionary>(); + foreach (FrameObjectSingleMesh m in all.Where(m => m.Geometry?.LOD != null)) + { + for (int lod = 0; lod < m.Geometry.LOD.Length; lod++) + { + if (m.GetIndexBuffer(lod) is not { } buffer) continue; + if (!blocksOfBuffer.TryGetValue(buffer.Hash, out HashSet? blocks)) + blocksOfBuffer[buffer.Hash] = blocks = new HashSet(ReferenceEqualityComparer.Instance); + blocks.Add(m.Geometry); + } + } + sb.AppendLine($"INFO {district}: {all.Count} mesh frames; {Shared(m => m.Geometry)} on a geometry block another frame uses, " + + $"{Shared(m => m.Material)} on a material block another frame uses ({materialAcrossGeometry} material block(s) under frames " + + $"of different geometry); {blocksOfBuffer.Count(b => b.Value.Count > 1)} of {blocksOfBuffer.Count} index buffers drawn by more than one geometry block"); + sb.AppendLine("INFO frames on a shared material block: " + string.Join(", ", all.Where(m => m.Refs.ContainsKey(FrameEntryRefTypes.Material)) + .GroupBy(m => (object)m.Material, ReferenceEqualityComparer.Instance).Where(g => g.Count() > 1).SelectMany(g => g).Take(12).Select(m => m.Name.ToString()))); + } + + // 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); + + // Picked by a click: a ray from just off the first triangle, straight at its middle, meets it; the + // plan made of that one pick hides it and - on the other levels - only what lies on it. + Vector3 middle = (corners[0] + corners[1] + corners[2]) / 3f; + Vector3 normal = Vector3.Normalize(Vector3.Cross(corners[1] - corners[0], corners[2] - corners[0])); + TriangleHider.Picked? hit = TriangleHider.Pick(mesh, world, middle + (normal * 0.02f), -normal); + Check("a ray at a triangle's middle meets it, from either side", + hit != null && MathF.Abs(hit.Distance - 0.02f) < 0.005f + && TriangleHider.Pick(mesh, world, middle - (normal * 0.02f), normal) is { } behind && MathF.Abs(behind.Distance - 0.02f) < 0.005f, + hit == null ? "no hit" : $"index {hit.Index} at {hit.Distance:F3} m (the first slot starts at {slot.StartIndex})"); + Check("a ray pointing away from the mesh meets nothing", + TriangleHider.Pick(mesh, world, max + new Vector3(5000f), Vector3.Normalize(Vector3.One)) == null); + if (hit != null) + { + TriangleHider.Plan picked = TriangleHider.FindPicked(mesh, world, new HashSet { hit.Index }); + Check("one pick is one triangle of the first level, the one that was met", + picked.Triangles.Count(t => t.Lod == 0) == 1 && picked.Triangles.First(t => t.Lod == 0) == hit.Triangle, + string.Join(", ", Enumerable.Range(0, mesh.Geometry.LOD.Length).Select(l => $"LOD {l}: {picked.Triangles.Count(t => t.Lod == l)}"))); + Vector3 lo = Vector3.Min(hit.Triangle.A, Vector3.Min(hit.Triangle.B, hit.Triangle.C)) - new Vector3(0.06f); + Vector3 hi = Vector3.Max(hit.Triangle.A, Vector3.Max(hit.Triangle.B, hit.Triangle.C)) + new Vector3(0.06f); + Check("what goes with it on the other levels lies on it", + picked.Triangles.Where(t => t.Lod > 0).All(t => new[] { t.A, t.B, t.C }.All(p => + p.X >= lo.X && p.Y >= lo.Y && p.Z >= lo.Z && p.X <= hi.X && p.Y <= hi.Y && p.Z <= hi.Z))); + Check("picking changes nothing", mesh.GetIndexBuffer(0)!.GetData().AsSpan().SequenceEqual(original)); + Check("no pick, or a pick that is no triangle's start, hides nothing", + TriangleHider.FindPicked(mesh, world, new HashSet()).Changes.Count == 0 + && TriangleHider.FindPicked(mesh, world, new HashSet { hit.Index + 1, -3, int.MaxValue }).Triangles.Count == 0); + foreach (TriangleHider.Change change in picked.Changes) change.Buffer.SetData(change.After); + Check("a hidden triangle is not met by the same ray again", + TriangleHider.Pick(mesh, world, middle + (normal * 0.02f), -normal)?.Index != hit.Index + && TriangleHider.FindPicked(mesh, world, new HashSet { hit.Index }).Triangles.Count == 0); + foreach (TriangleHider.Change change in picked.Changes) change.Buffer.SetData(change.Before); + Check("...and restoring puts every index back", mesh.GetIndexBuffer(0)!.GetData().AsSpan().SequenceEqual(original)); + } + + // A mesh whose geometry other frames draw too, given a block and buffers of its own - what lets its + // triangles be hidden without the others losing theirs. + var document = new Illusion.Assets.Adapters.SceneDocumentAdapter(scene.FrameResource!, sds); + FrameObjectSingleMesh? shared = scene.FrameResource!.FrameObjects.Values.OfType() + .FirstOrDefault(m => m.GetType() == typeof(FrameObjectSingleMesh) && m.Geometry?.LOD is { Length: > 0 } + && m.GetIndexBuffer(0) != null && document.GeometrySharers(m).Count > 0 && SdsMeshLoader.DecodeLod(m, 0) != null); + Check("a mesh that shares its geometry to try", shared != null, shared == null ? "none in this district" : + $"{shared.Name.String}: {document.GeometrySharers(shared).Count} other frame(s) on its block"); + if (shared != null) + { + byte[] sceneBefore = scene.FrameResource!.WriteToStream(); + Illusion.Formats.Frames.Resources.FrameGeometry block = shared.Geometry; + FrameObjectSingleMesh other = document.GeometrySharers(shared)[0]; + DecodedMesh was = SdsMeshLoader.DecodeLod(shared, 0)!; + uint[] sharedIndices = (uint[])shared.GetIndexBuffer(0)!.GetData().Clone(); + int vertexBuffers = scene.FrameResource.VertexBuffers.Buffers.Count, indexBuffers = scene.FrameResource.IndexBuffers.Buffers.Count; + + Illusion.Assets.Frames.FrameDuplicator.OwnedGeometry? owned = + Illusion.Assets.Frames.FrameDuplicator.TryOwnGeometry(document, document.Node(shared), out string? noCopy); + Check("it is given a block of its own, and the others stay on theirs", + owned != null && !ReferenceEquals(shared.Geometry, block) && ReferenceEquals(other.Geometry, block) + && document.GeometrySharers(shared).Count == 0, noCopy ?? ""); + if (owned != null) + { + DecodedMesh now = SdsMeshLoader.DecodeLod(shared, 0)!; + Check("...which holds what the shared one held, in buffers of its own", + now.Positions.AsSpan().SequenceEqual(was.Positions) && now.Indices.AsSpan().SequenceEqual(was.Indices) + && !ReferenceEquals(shared.GetIndexBuffer(0), other.GetIndexBuffer(0)) + && !ReferenceEquals(shared.GetVertexBuffer(0), other.GetVertexBuffer(0)) + && ReferenceEquals(owned.CopyOf(other.GetIndexBuffer(0)!), shared.GetIndexBuffer(0))); + Vector3[] tri = [.. Enumerable.Range(0, 3).Select(k => Vector3.Transform(now.Positions[now.Indices[k]], shared.WorldTransform))]; + TriangleHider.Plan alone = TriangleHider.Find(shared, shared.WorldTransform, + Vector3.Min(tri[0], Vector3.Min(tri[1], tri[2])) - new Vector3(0.05f), Vector3.Max(tri[0], Vector3.Max(tri[1], tri[2])) + new Vector3(0.05f)); + foreach (TriangleHider.Change change in alone.Changes) change.Buffer.SetData(change.After); + Check("triangles hidden in it are not hidden in the shared block", + alone.Changes.Count > 0 && other.GetIndexBuffer(0)!.GetData().AsSpan().SequenceEqual(sharedIndices) + && !shared.GetIndexBuffer(0)!.GetData().AsSpan().SequenceEqual(sharedIndices), $"{alone.Triangles.Count} triangle(s)"); + Check("the scene with the new block writes and reads back with the mesh on it", + Reread(scene.FrameResource.WriteToStream(), shared.Name.String) is { } again && again.Geometry.LOD.Length == shared.Geometry.LOD.Length + && again.Geometry.LOD[0].IndexBufferRef.Hash == shared.Geometry.LOD[0].IndexBufferRef.Hash); + owned.Revert(); + Check("taking it back leaves the scene and the pools as they were, byte for byte", + ReferenceEquals(shared.Geometry, block) && scene.FrameResource.WriteToStream().AsSpan().SequenceEqual(sceneBefore) + && scene.FrameResource.VertexBuffers.Buffers.Count == vertexBuffers && scene.FrameResource.IndexBuffers.Buffers.Count == indexBuffers + && shared.GetIndexBuffer(0)!.GetData().AsSpan().SequenceEqual(sharedIndices)); + } + } + } + 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()); + } + } + + // A scene written to memory, read again, and the mesh of that name in it. + private static FrameObjectSingleMesh? Reread(byte[] sceneBytes, string name) + { + string file = Path.Combine(Path.GetTempPath(), "illusion_hide_triangles_scene.fr"); + File.WriteAllBytes(file, sceneBytes); + try + { + return new FrameResource(file).FrameObjects.Values.OfType().FirstOrDefault(m => m.Name.String == name); + } + finally + { + File.Delete(file); + } + } +} diff --git a/src/Illusion/Diagnostics/Probes/MaterialEditorProbes.cs b/src/Illusion/Diagnostics/Probes/MaterialEditorProbes.cs index 393e0e0..b2f7a17 100644 --- a/src/Illusion/Diagnostics/Probes/MaterialEditorProbes.cs +++ b/src/Illusion/Diagnostics/Probes/MaterialEditorProbes.cs @@ -385,6 +385,44 @@ private static void CheckSlotReassign(Action check, String check("out-of-range slot is refused", !editor.SetSlotMaterial(9999, other), ""); editor.SetSlotMaterial(0, original); check("slot restored", editor.GetSlotMaterial(0) == original, ""); + + // A re-point mirrors itself into further levels by the slot's old material, and that cannot be run + // backwards: a level with slots A and B is B and B after "A to B", and "B to A" makes it A and A. So an + // undo puts the table back. Tried on a mesh whose further level holds the first level's material and + // another one beside it. + foreach (FrameObjectSingleMesh mesh in fr.FrameObjects.Values.OfType()) + { + if (doc.Node(mesh) is not IMaterialSlotEditor levels) continue; + IReadOnlyList shipped = levels.GetSlotTable(); + if (shipped.Count < 2 || shipped[0].Length == 0) continue; + ulong a = shipped[0][0]; + // Such a level is made where the district ships none: its first two slots are given the first + // level's material and another one. The shipped table goes back at the end. + int at = Enumerable.Range(1, shipped.Count - 1).FirstOrDefault(l => shipped[l].Length >= 2, -1); + if (at < 0) continue; + ulong b = a ^ 0x5555UL; + ulong[][] made = [.. shipped.Select(l => (ulong[])l.Clone())]; + (made[at][0], made[at][1]) = (a, b); + if (!levels.SetSlotTable(made)) continue; + IReadOnlyList before = levels.GetSlotTable(); + ulong[] further = before[at]; + + static bool Same(IReadOnlyList x, IReadOnlyList y) => x.Count == y.Count && x.Zip(y).All(p => p.First.SequenceEqual(p.Second)); + levels.SetSlotMaterial(0, b); + IReadOnlyList after = levels.GetSlotTable(); + levels.SetSlotMaterial(0, a); + bool backwardsLoses = !Same(levels.GetSlotTable(), before); + check("a re-point run backwards does not give the further level its two materials back", backwardsLoses, + $"{mesh.Name}: a further level bound {further.Distinct().Count()} material(s), {levels.GetSlotTable().Skip(1).First(l => l.Length == further.Length).Distinct().Count()} after"); + check("the table put back does", levels.SetSlotTable(after) && Same(levels.GetSlotTable(), after) + && levels.SetSlotTable(before) && Same(levels.GetSlotTable(), before), ""); + check("a table of another shape is refused and changes nothing", !levels.SetSlotTable([.. before.Skip(1)]) && Same(levels.GetSlotTable(), before), ""); + check("the shipped table is back", levels.SetSlotTable(shipped) && Same(levels.GetSlotTable(), shipped), ""); + return; + } + var shapes = fr.FrameObjects.Values.OfType().Select(m => doc.Node(m) is IMaterialSlotEditor e ? e.GetSlotTable() : []) + .GroupBy(t => string.Join("/", t.Select(l => l.Length))).OrderByDescending(g => g.Count()).Take(8).Select(g => $"{g.Key} x{g.Count()}"); + sb.AppendLine("(no mesh with a further level of two slots - the table check is skipped; slot counts per level: " + string.Join(", ", shapes) + ")"); } // The FULL assign path exactly as the user drives it: editor opened from a mesh tile (ShowMaterial 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/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; + } +} 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, + }; + } +} diff --git a/src/Illusion/Mcp/AppEditorSession.cs b/src/Illusion/Mcp/AppEditorSession.cs index 2e9b54c..a84b40e 100644 --- a/src/Illusion/Mcp/AppEditorSession.cs +++ b/src/Illusion/Mcp/AppEditorSession.cs @@ -263,17 +263,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, lo is { } boxLo && hi is { } boxHi ? (boxLo, boxHi) : null); 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)) @@ -285,14 +286,14 @@ 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)); } if (kind == null || string.Equals(kind, CrashCopyKind, StringComparison.OrdinalIgnoreCase)) @@ -396,16 +397,87 @@ private static int TrianglesInBox( return count; } + /// 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(); + + // A decoded hull and the box of its vertices in its own space - what a placement of it is first tried against. + private sealed record Hull(Vector3[] Vertices, uint[] Triangles, Vector3 Min, Vector3 Max); + + /// A world box the caller is asking about: a hull whose own box, stood where the placement + /// stands, does not reach it is answered with that looser box and without its vertices being put through + /// the placement's matrix - a search of a district tries every placement of every collision file. + private static Shape? ShapeOf(SceneNode node, (Vector3 Min, Vector3 Max)? box = null) + { + 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; + + // by the file's own index of its hulls: a scan of the list for every placement was hulls x placements + byte[]? cooked = placement.Document.MeshFor(placement.Instance.Hash)?.CookedMesh; + if (cooked == null) return null; + Hull hull = Hulls.GetValue(cooked, static bytes => + { + try + { + Formats.Collisions.CookedTriangleMesh decoded = Formats.Collisions.CookedTriangleMesh.Decode(bytes); + var lo = new Vector3(float.MaxValue); + var hi = new Vector3(float.MinValue); + foreach (Vector3 vertex in decoded.Vertices) + { + lo = Vector3.Min(lo, vertex); + hi = Vector3.Max(hi, vertex); + } + return new Hull(decoded.Vertices, Array.ConvertAll(decoded.Triangles, i => (uint)i), lo, hi); + } + catch (Formats.Collisions.CollisionDecodeException) + { + return new Hull([], [], default, default); + } + }); + if (hull.Vertices.Length == 0) return null; + + Matrix4x4 world = placement.WorldTransform; + var min = new Vector3(float.MaxValue); + var max = new Vector3(float.MinValue); + if (box is { } asked) + { + // the eight corners of the hull's own box: it holds every vertex, so a miss here is a miss + for (int i = 0; i < 8; i++) + { + Vector3 corner = Vector3.Transform(new Vector3((i & 1) == 0 ? hull.Min.X : hull.Max.X, + (i & 2) == 0 ? hull.Min.Y : hull.Max.Y, (i & 4) == 0 ? hull.Min.Z : hull.Max.Z), world); + min = Vector3.Min(min, corner); + max = Vector3.Max(max, corner); + } + if (!Overlaps(min, max, asked.Min, asked.Max)) return new Shape(hull.Vertices, hull.Triangles, world, min, max); + min = new Vector3(float.MaxValue); + max = new Vector3(float.MinValue); + } + foreach (Vector3 vertex in hull.Vertices) + { + Vector3 at = Vector3.Transform(vertex, world); + min = Vector3.Min(min, at); + max = Vector3.Max(max, at); + } + return new Shape(hull.Vertices, hull.Triangles, 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) { @@ -1126,6 +1198,197 @@ private static IEnumerable CarArchives(string car) return null; } + public string? HideTriangles(string name, float[] boxMin, float[] boxMax, string? material, bool apply, int sample, string? shared, + out HiddenTrianglesInfo? result) + { + result = null; + if (TargetHost is not { } host) return TargetNotOpen; + GeometryEditController.SharedGeometry sharing; + if (string.IsNullOrWhiteSpace(shared)) sharing = GeometryEditController.SharedGeometry.Refuse; + else if (string.Equals(shared, "own", StringComparison.OrdinalIgnoreCase)) sharing = GeometryEditController.SharedGeometry.OwnCopy; + else if (string.Equals(shared, "all", StringComparison.OrdinalIgnoreCase)) sharing = GeometryEditController.SharedGeometry.All; + else return "shared is 'own' or 'all', or left out"; + if (boxMin is not { Length: 3 } || boxMax is not { Length: 3 } || boxMin.Concat(boxMax).Any(v => !float.IsFinite(v))) + return "boxMin and boxMax are [x, y, z], finite numbers"; + if (Resolve(host, name, out SceneNode? node) is { } unresolved) return unresolved; + if (apply && host.BridgeEditedCount > 0) return "a Blender session is open — blender_end first"; + if (PlanHiddenTriangles(node!, new Vector3(boxMin[0], boxMin[1], boxMin[2]), new Vector3(boxMax[0], boxMax[1], boxMax[2]), + material, out Assets.Sds.TriangleHider.Plan? found) is { } notAMesh) + { + return notAMesh; + } + Assets.Sds.TriangleHider.Plan plan = found!; + if (apply && plan.Changes.Count > 0 && host.GeometryEditing.HideTriangles(node!, plan.Changes, sharing) is { } refused) + { + return sharing == GeometryEditController.SharedGeometry.Refuse && host.GeometryEditing.GeometrySharersOf(node!) is { Count: > 0 } others + ? $"{refused} ({others.Count}: {others.Names}) — shared: 'own' cuts this mesh alone, 'all' cuts every one of them" + : 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; + } + + /// The triangles of a scene node's mesh whose corners all lie inside a world-space box, and the index + /// edits that hide them. One implementation for the tool and for Tools → Hide triangles. Null on success. + internal static string? PlanHiddenTriangles(SceneNode node, Vector3 min, Vector3 max, string? material, + out Assets.Sds.TriangleHider.Plan? plan) + { + plan = null; + if (node.Source is not Assets.Adapters.FrameNodeAdapter { Frame: Formats.Frames.ObjectTypes.FrameObjectSingleMesh mesh } adapter) + { + return $"'{node.Name}' is a {node.Kind} — only a mesh has triangles to hide"; + } + plan = Assets.Sds.TriangleHider.Find(mesh, ((IFrameNode)adapter).WorldTransform, min, max, + string.IsNullOrWhiteSpace(material) ? null : material); + return null; + } + + /// The same for triangles picked one by one on the mesh's first level of detail (Tools → Hide + /// triangles, by clicking): the plan that hides them and what lies on them on the other levels, and how many + /// levels the mesh has. Null on success. + internal static string? PlanPickedTriangles(SceneNode node, IReadOnlyCollection picked, out Assets.Sds.TriangleHider.Plan? plan, + out int levels) + { + plan = null; + levels = 0; + if (node.Source is not Assets.Adapters.FrameNodeAdapter { Frame: Formats.Frames.ObjectTypes.FrameObjectSingleMesh mesh } adapter) + { + return $"'{node.Name}' is a {node.Kind} — only a mesh has triangles to hide"; + } + levels = mesh.Geometry?.LOD?.Length ?? 0; + plan = Assets.Sds.TriangleHider.FindPicked(mesh, ((IFrameNode)adapter).WorldTransform, picked); + return null; + } + + public string? MeshMaterials(string name, int? slot, string? material, out IReadOnlyList slots) + { + slots = []; + if (TargetHost is not { } host) return TargetNotOpen; + if (Resolve(host, name, out SceneNode? node) is { } unresolved) return unresolved; + if (node!.Source is not Assets.Adapters.FrameNodeAdapter adapter || adapter.GetMaterials() is not { Count: > 0 } before) + { + return $"'{name}' is a {node.Kind} with no material slots"; + } + + int changed = -1; + if (slot != null || !string.IsNullOrWhiteSpace(material)) + { + if (slot is not { } index || string.IsNullOrWhiteSpace(material)) return "to re-point a slot give both: slot and material"; + if (index < 0 || index >= before.Count) return $"'{name}' has slots 0 to {before.Count - 1}"; + if (host.BridgeEditedCount > 0) return "a Blender session is open — blender_end first"; + if (Assets.MafiaMaterials.FindHashByName(material) is not { } hash) + { + return $"no material named '{material}' in the loaded libraries — search_materials has the names (the spelling is exact)"; + } + // The slot is a row of the mesh's material block, and a block can serve several objects: all of them + // would change in the file and in the game, while this answer - and the viewport - showed one. + if (host.SlotAssignObstacle(node) is { } shared) return shared; + if (!host.AssignSlotMaterial(node, index, hash)) return $"slot {index} of '{name}' could not be re-pointed"; + changed = index; + } + slots = [.. adapter.GetMaterials().Select((m, i) => new MeshSlotInfo(i, m.Name, m.TriangleCount, i == changed))]; + return null; + } + + public string? UnusedHulls(bool apply, out IReadOnlyList result) + { + result = []; + if (TargetHost is not { } host) return TargetNotOpen; + if (apply && host.BridgeEditedCount > 0) return "a Blender session is open — blender_end first"; + + // Counted before the sweep: afterwards a layer's hull list no longer says what it carried. + var layers = AllNodes(host) + .Where(n => n.Source is Assets.Adapters.CollisionDocumentAdapter) + .Select(n => (Node: n, File: ((Assets.Adapters.CollisionDocumentAdapter)n.Source!).Collision)) + .Select(l => (l.Node, Placements: l.File.Instances.Count, Hulls: l.File.Meshes.Count)) + .ToList(); + if (layers.Count == 0) return "the open scene has no collision file"; + + Dictionary unused = host.CollisionEditing.SweepUnusedHulls(layers.Select(l => l.Node), apply) + .ToDictionary(l => l.Layer, l => l.Unused); + result = [.. layers.Select(l => + { + int n = unused.GetValueOrDefault(l.Node); + return new UnusedHullsInfo(PathOf(l.Node), l.Placements, l.Hulls, n, apply && n > 0); + })]; + return null; + } + + public string? CrashPlacements(float[] boxMin, float[] boxMax, string? nameContains, bool delete, int limit, int maxDelete, + out IReadOnlyList result, out int total) + { + result = []; + total = 0; + 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]"; + // A box that holds nothing by construction answers "count 0", and that reads as "nothing stands here". + if (boxMin.Concat(boxMax).Any(v => !float.IsFinite(v))) return "boxMin and boxMax must be finite numbers"; + for (int axis = 0; axis < 3; axis++) + { + if (boxMin[axis] > boxMax[axis]) + { + return $"boxMin is above boxMax on {"xyz"[axis]} ({boxMin[axis]} > {boxMax[axis]}) — such a box holds " + + "nothing; give the lower corner first"; + } + } + if (host.Streamer.CrashLayer is not { } layer) return "the crash layer is not in the scene — view_set crash=true first"; + if (delete && host.BridgeEditedCount > 0) return "a Blender session is open — blender_end first"; + + var lo = new Vector3(boxMin[0], boxMin[1], boxMin[2]); + var hi = new Vector3(boxMax[0], boxMax[1], boxMax[2]); + var hits = new List<(Formats.Translokator.Object Row, Formats.Translokator.Instance Placement)>(); + foreach (Formats.Translokator.Object row in layer.Rows) + { + if (!string.IsNullOrWhiteSpace(nameContains) + && !row.Name.String.Contains(nameContains, StringComparison.OrdinalIgnoreCase)) continue; + foreach (Formats.Translokator.Instance placement in row.Instances) + { + Vector3 p = placement.Position; + if (p.X >= lo.X && p.X <= hi.X && p.Y >= lo.Y && p.Y <= hi.Y && p.Z >= lo.Z && p.Z <= hi.Z) + hits.Add((row, placement)); + } + } + + total = hits.Count; + // A wide box with no name is the whole city table — tens of thousands of placements and their twins as + // one edit, of which the list would show the first two hundred. Refused whole instead: the caller + // says how many it means to remove. + if (delete && hits.Count > Math.Max(0, maxDelete)) + { + return $"{hits.Count} placements are in the box — more than maxDelete ({maxDelete}); nothing was deleted. " + + "List them first (delete=false), narrow the box or the name, or raise maxDelete"; + } + // A delete lists everything it removed, whatever the listing limit: what went has to be readable. + result = [.. hits.Take(delete ? hits.Count : Math.Max(0, limit)).Select(h => new CrashPlacementInfo( + h.Row.Name.String, h.Placement.ID, [h.Placement.Position.X, h.Placement.Position.Y, h.Placement.Position.Z], + layer.Document.HasTwinOf(h.Placement, h.Row), layer.Document.Node(h.Placement, h.Row).SeasonLinked))]; + if (!delete || hits.Count == 0) return null; + + // The viewport's own delete, so the edit is the one the Delete key makes: undoable, the streaming grid + // kept in step, the twin in the other season gone with a linked placement. + List nodes = [.. hits.Select(h => host.Streamer.CrashNodeFor(h.Placement, h.Row)).OfType()]; + if (nodes.Count != hits.Count) return "some placements could not be given a tree node — nothing was deleted"; + // That delete works on the selection. What the user had selected is put back afterwards, less + // whatever of it was just deleted — a tool that lists and removes props has no business leaving the + // editor with nothing selected. + List selectedBefore = [.. host.Selection.Selected]; + SceneNode? activeBefore = host.Selection.Active; + host.Selection.SetSelection(nodes, nodes[^1]); + host.CrashEditing.DeleteSelected(); + var gone = new HashSet(nodes); + List kept = [.. selectedBefore.Where(n => !gone.Contains(n))]; + host.Selection.SetSelection(kept, + activeBefore != null && kept.Contains(activeBefore) ? activeBefore : kept.Count > 0 ? kept[^1] : null); + return null; + } + public string? Undo() { if (TargetHost is not { } host) return TargetNotOpen; 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/Viewport/CollisionEditController.cs b/src/Illusion/Viewport/CollisionEditController.cs index 4f6073a..c6972f4 100644 --- a/src/Illusion/Viewport/CollisionEditController.cs +++ b/src/Illusion/Viewport/CollisionEditController.cs @@ -34,13 +34,8 @@ internal sealed class CollisionEditController : ICollisionEditSink /// hulls" command would sweep. Zero disables it, so a modder can read the file's state off the menu instead /// of guessing whether a delete left anything behind. /// - public int UnusedHullCount() - { - int total = 0; - foreach ((CollisionDocumentAdapter doc, SceneNode layer) in SelectedCollisionLayers()) - total += CollisionOrphanEdit.Build(this, doc, layer)?.Count ?? 0; - return total; - } + public int UnusedHullCount() => + SweepUnusedHulls(SelectedCollisionLayers().Select(l => l.Layer), apply: false).Sum(l => l.Unused); /// /// Removes every hull no placement references, as one undoable edit per layer. Never runs on its own: a @@ -49,20 +44,33 @@ public int UnusedHullCount() /// public void RemoveUnusedHulls() { - int removed = 0; + if (SweepUnusedHulls(SelectedCollisionLayers().Select(l => l.Layer), apply: true).Sum(l => l.Unused) == 0) + _host.RaiseNotice("no unused hulls — every hull in this .col is placed"); + } + + /// + /// The same sweep for layers named outright instead of reached through the selection — what the MCP tool + /// drives, having no selection to go by. Reports each layer's unused hulls; with + /// removes them, all layers as one undoable edit. + /// + public IReadOnlyList<(SceneNode Layer, int Unused)> SweepUnusedHulls(IEnumerable layers, bool apply) + { + var found = new List<(SceneNode Layer, int Unused)>(); var edits = new List(); - foreach ((CollisionDocumentAdapter doc, SceneNode layer) in SelectedCollisionLayers()) + foreach (SceneNode layer in layers) { + if (layer.Source is not CollisionDocumentAdapter doc) continue; CollisionOrphanEdit? edit = CollisionOrphanEdit.Build(this, doc, layer); - if (edit == null) continue; + found.Add((layer, edit?.Count ?? 0)); + if (edit == null || !apply) continue; edit.Redo(); edits.Add(edit); - removed += edit.Count; } - if (edits.Count == 0) { _host.RaiseNotice("no unused hulls — every hull in this .col is placed"); return; } + if (edits.Count == 0) return found; History.Push(edits.Count == 1 ? edits[0] : new CompositeEdit(edits.ToArray())); - _host.RaiseNotice($"removed {removed} unused hull(s) — Ctrl+Z restores them"); + _host.RaiseNotice($"removed {found.Sum(l => l.Unused)} unused hull(s) — Ctrl+Z restores them"); + return found; } // The collision layers the selection reaches: the layer node itself when it is selected, or the layer behind diff --git a/src/Illusion/Viewport/D3DImageHost.cs b/src/Illusion/Viewport/D3DImageHost.cs index 76e76c7..7b866d7 100644 --- a/src/Illusion/Viewport/D3DImageHost.cs +++ b/src/Illusion/Viewport/D3DImageHost.cs @@ -864,6 +864,9 @@ public bool AddMaterialParameter(ulong hash, string paramId, IReadOnlyList MaterialEditing.AssignSlotMaterial(node, slotIndex, newHash); + /// + public string? SlotAssignObstacle(SceneNode node) => MaterialEditing.SlotAssignObstacle(node); + /// Whether is still part of the loaded scene tree — actions pinned /// to a node across scene reloads (the material editor's assign target) validate with this. public bool IsNodeInScene(SceneNode node) => Tree.IsInScene(node); @@ -1142,6 +1145,10 @@ public readonly record struct BuildReport( /// public BuildReport BuildEdits(bool createBackup = true) => Persistence.BuildEdits(createBackup); + /// + public BuildReport BuildArchives(IReadOnlyList archives, bool createBackup = true) => + Persistence.BuildArchives(archives, createBackup); + /// public void PrepareForArchiveRestore() => Streamer.ResetForExternalChange(); @@ -1171,6 +1178,10 @@ public readonly record struct BuildReport( // multi-selection (a miss is ignored); a plain click replaces the selection (or clears it on a miss). protected override void OnViewportLeftClick(Point pos) { + // A tool that is picking on the scene (Tools → Hide triangles picks triangles) is offered the click + // first; what it does not take is an ordinary click. + if (SceneClickTaker is { } taker && taker(pos)) return; + SceneNode? hit = PickNode(pos); if ((Keyboard.Modifiers & ModifierKeys.Control) != 0) { @@ -1179,6 +1190,63 @@ protected override void OnViewportLeftClick(Point pos) else Select(hit); } + /// While set, a left click on the scene is offered to it before anything is selected; true means + /// the click was its own. One tool at a time: the tool that sets it clears it. + internal Func? SceneClickTaker { get; set; } + + /// + /// The triangle of a mesh node under a screen pixel, as the eye has it: of the first level of detail, and + /// only when nothing of the scene stands in front of it there. Null when the node is not a mesh, the pixel + /// misses it, or something covers it - then says which, for the user. + /// + internal TriangleHider.Picked? PickTriangle(SceneNode node, Point pos, out string? missed) + { + missed = null; + if (node.Source is not Assets.Adapters.FrameNodeAdapter { Frame: Formats.Frames.ObjectTypes.FrameObjectSingleMesh mesh } adapter) return null; + (Vector3 origin, Vector3 dir) = BuildViewportRay(pos); + TriangleHider.Picked? hit = TriangleHider.Pick(mesh, ((IFrameNode)adapter).WorldTransform, origin, dir); + if (hit == null) + { + missed = "That spot is not on this mesh."; + return null; + } + // Nearer scenery at this pixel means the triangle is behind it: what cannot be seen is not picked. The + // mesh's own rows do not count as scenery - from a distance the editor draws a coarser level of it, whose + // surface need not lie where the first level's does, and that level stood "in front" of every triangle. + SceneNode? front = PickNode(pos); + if (front != null && (ReferenceEquals(front, node) || ReferenceEquals(front.Source, node.Source))) return hit; + float nearest = PickMesh(pos, out float meshT) != null ? meshT : float.PositiveInfinity; + if (nearest >= hit.Distance - MathF.Max(0.05f, hit.Distance * 0.002f)) return hit; + missed = $"{front?.Name ?? "Something"} stands in front of the mesh there."; + return null; + } + + /// Marks triangles in the viewport as a tool's pick - their edges, and a stroke to each corner so a + /// small one still reads as filled. An empty list takes the marks away. + internal void ShowPickedTriangles(IReadOnlyList triangles) + { + if (Rnd == null) return; + var lines = new List(triangles.Count * 12); + var colours = new List(triangles.Count * 12); + var edge = new Vector4(1f, 0.62f, 0.22f, 1f); + var inner = new Vector4(1f, 0.62f, 0.22f, 0.45f); + foreach (TriangleHider.Triangle t in triangles) + { + Vector3 centre = (t.A + t.B + t.C) / 3f; + foreach ((Vector3 from, Vector3 to, Vector4 colour) in new[] + { + (t.A, t.B, edge), (t.B, t.C, edge), (t.C, t.A, edge), (centre, t.A, inner), (centre, t.B, inner), (centre, t.C, inner), + }) + { + lines.Add(from); + lines.Add(to); + colours.Add(colour); // one for each end of the line + colours.Add(colour); + } + } + Rnd.SetPickedTriangleLines(lines, colours); + } + // ── Glyph hover: what the cursor is over, highlighted and named ── /// The glyph object under the cursor (helper node or bone), or null. Highlighted in the viewport diff --git a/src/Illusion/Viewport/GeometryEditController.cs b/src/Illusion/Viewport/GeometryEditController.cs index 36e3b44..706f67b 100644 --- a/src/Illusion/Viewport/GeometryEditController.cs +++ b/src/Illusion/Viewport/GeometryEditController.cs @@ -1,6 +1,8 @@ using System.Numerics; using Illusion.Assets.Adapters; using Illusion.Assets.Bridge; +using Illusion.Assets.Frames; +using Illusion.Assets.Sds; using Illusion.Domain; using Illusion.Formats.Frames.ObjectTypes; using Illusion.Rendering.Gpu; @@ -171,6 +173,208 @@ public List ApplyPushBatch( return outcomes; } + /// What hiding triangles does about a mesh whose geometry other objects draw too. + public enum SharedGeometry + { + /// Nothing is hidden: the triangles would vanish from every object drawing it. + Refuse, + + /// The mesh gets a geometry block and buffers of its own first, and loses the triangles alone. + OwnCopy, + + /// The triangles vanish from every object drawing the geometry - one shape, cut once. + All, + } + + /// How many other objects draw the geometry of this node's mesh, and the first few by name. + public (int Count, string Names) GeometrySharersOf(SceneNode node) + { + if (node.Kind == "Lod" && node.Parent is { } owner && ReferenceEquals(owner.Source, node.Source)) node = owner; + if (node.Source is not FrameNodeAdapter { Frame: FrameObjectSingleMesh mesh } adapter) return (0, ""); + IReadOnlyList others = adapter.Document.GeometrySharers(mesh); + return (others.Count, string.Join(", ", others.Take(4).Select(o => o.Name.String)) + (others.Count > 4 ? ", ..." : "")); + } + + /// + /// 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. A mesh that shares its + /// geometry is refused, given a copy of its own first, or cut together with the others, as + /// says; an instanced one is refused. + /// + /// Null when applied, or why not. + public string? HideTriangles(SceneNode node, IReadOnlyList changes, SharedGeometry shared = SharedGeometry.Refuse) + { + // A click in the viewport on a mesh of several levels picks the row of the level that is drawn ("LOD 0"), + // not the mesh's own row. The edit is the mesh's: every level's buffer changes, and every level's row + // has to be redrawn - from the level's row alone the others went on drawing triangles the data had lost. + if (node.Kind == "Lod" && node.Parent is { } owner && ReferenceEquals(owner.Source, node.Source)) node = owner; + if (node.Source is not FrameNodeAdapter { Frame: FrameObjectSingleMesh mesh } adapter) return "not a mesh"; + if (changes.Count == 0) return "nothing to hide"; + // Asked of every row that draws the mesh: one of several levels keeps its GPU meshes on the level rows, + // and its own row has none to ask. + if (LevelRows(node).Any(r => r.Mesh is { Instanced: true })) + return "an instanced mesh is drawn many times over — its triangles cannot be hidden in one place"; + bool sharing = adapter.Document.GeometrySharers(mesh).Any(); + if (sharing && shared == SharedGeometry.Refuse) return "other objects draw the same geometry — the triangles would vanish from all of them"; + // Two geometry blocks can stand on one index buffer: an object imported twice, with a level's distance + // changed in between, gets a block of its own over the buffers it already brought. A mesh about to get + // copies of its buffers leaves that buffer to the other block. + if (!(sharing && shared == SharedGeometry.OwnCopy) + && adapter.Document.IndexBufferSharers(mesh, changes.Select(c => c.Buffer.Hash).ToHashSet()).Any()) + return "another object draws from the same index buffer — the triangles would vanish from it as well"; + // The plan is the buffers as they were when it was made, and what they become. Made earlier and applied + // now - by a window that counted before something else changed the mesh - it would write over that change. + if (changes.Any(c => !ReferenceEquals(c.Buffer.GetData(), c.Before))) + return "the mesh changed since these triangles were counted — count them again"; + if (_host.Rnd == null) return "the viewport is not rendering yet"; + + if (sharing && shared == SharedGeometry.OwnCopy) + { + // Its own block and buffers first - what a duplicate of the object gets - and the plan carried over + // to them: the copies hold what the shared buffers held, index for index. + if (FrameDuplicator.TryOwnGeometry(adapter.Document, adapter, out string? noCopy) is not { } owned) + return "the mesh could not be given geometry of its own: " + noCopy; + var carried = new List(); + foreach (TriangleHider.Change change in changes) + { + if (owned.CopyOf(change.Buffer) is not { } copy) + { + owned.Revert(); + return "the mesh changed since these triangles were counted — count them again"; + } + carried.Add(new TriangleHider.Change(copy, copy.GetData(), change.After)); + } + var alone = new HiddenTrianglesEdit(this, node, adapter.Document, mesh, carried, []); + alone.Redo(); + _host.Editing.History.Push(new CompositeEdit([new OwnGeometryEdit(this, node, owned), alone])); + _host.RaiseSceneChanged(); + return null; + } + + // Every object drawing the block is redrawn with it: there is one buffer in the file. Their rows are + // taken whether or not the row itself carries a mesh - an object of several levels draws from its level + // rows - and an instanced one among them cannot be redrawn as a single mesh. + List others = []; + if (sharing) + { + foreach (FrameObjectSingleMesh other in adapter.Document.GeometrySharers(mesh)) + { + if (_host.Streamer.Actors.RowOf(other) is { } row && _host.Tree.IsInScene(row)) others.Add(row); + } + if (others.SelectMany(LevelRows).Any(r => r.Mesh is { Instanced: true })) + return "one of the objects drawing this geometry is instanced — cut this object alone instead"; + } + var edit = new HiddenTrianglesEdit(this, node, adapter.Document, mesh, changes, others); + edit.Redo(); + _host.Editing.History.Push(edit); + _host.RaiseSceneChanged(); + return null; + } + + /// A mesh put on a geometry block of its own, and back on the shared one. Nothing is redrawn: the + /// copy holds what the shared block held. + private sealed class OwnGeometryEdit(GeometryEditController owner, SceneNode node, FrameDuplicator.OwnedGeometry owned) : INodeEdit + { + public IEnumerable Nodes { get { yield return node; } } + + public void Undo() + { + owned.Revert(); + owner._host.Persistence.MarkFrameModified(node); + } + + public void Redo() + { + owned.Apply(); + owner._host.Persistence.MarkFrameModified(node); + } + } + + // 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; + + /// The rows of the other objects that draw the same geometry block, when the + /// triangles are hidden in the shared block: they lose them too, and are redrawn with this one. + public HiddenTrianglesEdit(GeometryEditController owner, SceneNode node, SceneDocumentAdapter document, + FrameObjectSingleMesh mesh, IReadOnlyList changes, IReadOnlyList sharers) + { + _owner = owner; + _node = node; + _document = document; + _mesh = mesh; + _changes = changes; + foreach (SceneNode row in LevelRows(node)) _rows.Add((row, row.Mesh, null)); + foreach (SceneNode other in sharers) + { + foreach (SceneNode row in LevelRows(other)) _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 + // a row of another object on the same block is built from ITS frame: the block is shared, the + // materials need not be + FrameObjectSingleMesh drawn = row.Source is FrameNodeAdapter { Frame: FrameObjectSingleMesh own } ? own : _mesh; + if (gone == null && hidden && SdsMeshLoader.BuildMeshData(drawn, 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) diff --git a/src/Illusion/Viewport/MaterialEditController.cs b/src/Illusion/Viewport/MaterialEditController.cs index f2b698f..363b2d3 100644 --- a/src/Illusion/Viewport/MaterialEditController.cs +++ b/src/Illusion/Viewport/MaterialEditController.cs @@ -264,19 +264,40 @@ public bool DeleteMaterial(ulong hash) public bool AssignSlotMaterial(SceneNode node, int slotIndex, ulong newHash) { if (node.Source is not IMaterialSlotEditor editor || !_host.Tree.IsInScene(node)) return false; + if (SlotAssignObstacle(node) != null) return false; ulong? before = editor.GetSlotMaterial(slotIndex); if (before == null) return false; if (before == newHash) return true; + IReadOnlyList tableBefore = editor.GetSlotTable(); if (!editor.SetSlotMaterial(slotIndex, newHash)) return false; - _host.Editing.History.Push(new SlotEdit(this, node, slotIndex, before.Value, newHash)); + _host.Editing.History.Push(new SlotEdit(this, node, slotIndex, before.Value, newHash, tableBefore, editor.GetSlotTable())); AfterSlotChanged(node, slotIndex, newHash); return true; } - private void ApplySlot(SceneNode node, int slotIndex, ulong hash) + // The whole table goes back, every level of it: the re-point mirrored itself into further levels by the + // slot's old hash, and the same rule run the other way turns a level that had two materials into one. + /// + /// Why a slot of this node's mesh cannot be re-pointed, or null. A slot is a row of the mesh's material + /// block, and a block can serve several objects: all of them would change in the file and in the game, + /// while the viewport redraws - and the caller is told about - one. + /// + public string? SlotAssignObstacle(SceneNode node) + { + if (node.Source is not Assets.Adapters.FrameNodeAdapter { Frame: Formats.Frames.ObjectTypes.FrameObjectSingleMesh mesh } adapter + || adapter.Document.MaterialSharers(mesh) is not { Count: > 0 } sharers) + { + return null; + } + return $"'{node.Name}' draws with a material block {sharers.Count} other object(s) use too (" + + string.Join(", ", sharers.Take(5).Select(s => s.Name.ToString())) + (sharers.Count > 5 ? ", …" : "") + + ") — re-pointing the slot would change them all"; + } + + private void ApplySlot(SceneNode node, int slotIndex, ulong hash, IReadOnlyList table) { if (node.Source is not IMaterialSlotEditor editor || !_host.Tree.IsInScene(node)) return; - if (editor.SetSlotMaterial(slotIndex, hash)) AfterSlotChanged(node, slotIndex, hash); + if (editor.SetSlotTable(table) || editor.SetSlotMaterial(slotIndex, hash)) AfterSlotChanged(node, slotIndex, hash); } // ── Shared after-effects ── @@ -545,18 +566,23 @@ private sealed class SlotEdit : INodeEdit private readonly int _slotIndex; private readonly ulong _before; private readonly ulong _after; + private readonly IReadOnlyList _tableBefore; + private readonly IReadOnlyList _tableAfter; - public SlotEdit(MaterialEditController owner, SceneNode node, int slotIndex, ulong before, ulong after) + public SlotEdit(MaterialEditController owner, SceneNode node, int slotIndex, ulong before, ulong after, + IReadOnlyList tableBefore, IReadOnlyList tableAfter) { _owner = owner; _node = node; _slotIndex = slotIndex; _before = before; _after = after; + _tableBefore = tableBefore; + _tableAfter = tableAfter; } public IEnumerable Nodes { get { yield return _node; } } - public void Undo() => _owner.ApplySlot(_node, _slotIndex, _before); - public void Redo() => _owner.ApplySlot(_node, _slotIndex, _after); + public void Undo() => _owner.ApplySlot(_node, _slotIndex, _before, _tableBefore); + public void Redo() => _owner.ApplySlot(_node, _slotIndex, _after, _tableAfter); } } diff --git a/src/Illusion/Viewport/ScenePersistence.cs b/src/Illusion/Viewport/ScenePersistence.cs index 52b64ed..5b9a692 100644 --- a/src/Illusion/Viewport/ScenePersistence.cs +++ b/src/Illusion/Viewport/ScenePersistence.cs @@ -145,14 +145,19 @@ public int SaveEdits(List? notSaved = null) /// backup first (all archives in this build share one timestamp). Each archive is packed independently: a /// failure is captured (not thrown) so the remaining archives still build, and only the ones that packed /// leave the list — the rest stay buildable for a retry. - 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/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/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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +