Skip to content

feat: push materials made in Blender into the game (diffuse, normal/specular) - #4

Merged
Segfaultd merged 14 commits into
mafia2online:mainfrom
raighen:pr/blender-material-push
Oct 7, 2026
Merged

Segfaultd merged 14 commits into
mafia2online:mainfrom
raighen:pr/blender-material-push

Conversation

@raighen

@raighen raighen commented Oct 2, 2026 •

Copy link
Copy Markdown
Contributor

What

A material created in Blender now comes across the bridge with the object that wears it. Until now the bridge refused any material the toolkit had not handed out itself, so a new object could only reuse stock materials.

  • Diffuse — an image on Base Color becomes a game material (Default preset) plus its texture.
  • Normal + specular — a Normal Map node adds the combined NS texture (normal XY in R/G, specular level in B; green is flipped from Blender's convention), on the diffuse+normal shader.
  • The material is written to the library on Save; its textures are packed into the archive on Build.
  • Material Editor: preview a material on the mesh slot it was opened from, not only on the sphere.

Fixes found on the way (each one was a black or missing object in the game)

  • Textures with both sides ≥ 256 are stored split, as the shipped ones are: top level alone in a Mipmap entry (MIP_<name>.dds), the rest of the chain in the Texture entry with HasMIP = 1.
  • A Texture resource's VRAM figure includes its MIP companion's payload.
  • Build no longer drops resources whose manifest name is rooted (/missions/...) — it removed a district's AudioSectors.
  • A created material carries Unk0 = 128 and TexType = 2 on its sampler, like the stock materials on that shader (1837/1929 and 1947/1951 of them).
  • The Material Editor no longer throws while its XAML loads.

How it was checked

  • --probe-bridge-material: 33 passed, 0 failed. --probe-bridge-material-live drives a real viewport + bridge session against a running Blender.
  • --probe-material-editor 65/0, --probe-bridge-rebuild 17/0, --probe-bridge-newobj 6/0, --probe-save 7/0, --probe-content-edit 35/0, --probe-native-misc 31/0 — the same numbers main gives on the same install.
  • CI build (-c Release -p:MfCoreMode=Prebuilt) clean, dotnet format clean on every touched file.
  • In the game: a billboard in italy and a full interior in uppertown (11 materials, 13 textures) render with their own textures, normal and specular maps.

Limits

Opaque only — alpha is not carried. Material-library edits stay outside the backup flow, as before.

The MCP editor tools, actor import and light placement built on top of this are in #3.

Local work found uncommitted in the working copy (base a55dc22, v0.3.1):
sphere/mesh preview toggle, the slot label on the assign panel, and the
SDK pin moved to 10.0.302.
A push used to refuse any slot whose material had not come from the
toolkit. A Blender-made material now travels with the image wired into
its Principled Base Color and becomes a real game material:

- addon: sends the image as RGBA8 (top-down, resampled to powers of two)
  once per push however many slots use it; the ack stamps the hash back
  on the datablock so later pushes send pixels only when they changed
- DdsEncoder: DXT1 with a full MIP chain and the stock header, written
  as a single Texture entry (HasMIP 0) - the shape 2809 stock city
  textures ship in, so no companion Mipmap entry is needed
- AuthoredMaterialResolver: fills in the hash before the mesh path runs;
  binds by name when the game already has the material, otherwise writes
  the texture into the object's archive (file + manifest) and creates a
  default-preset material with it in S000 (one undo entry)
- a re-push with new pixels rewrites the texture in place and the
  renderer reloads it (TextureLibrary.Invalidate)

Probe: --probe-bridge-material [district] [push.ilx] - encoder, resolver,
re-push, bind-by-name, refusal, and optionally a container written by
the addon itself.
The Sphere toggle is checked in markup, so its Checked handler ran before
the Mesh toggle and the preview existed and the window died with a
NullReferenceException on open. The handler now waits for both.

The probe read the assign BUTTON's visibility; the button is hidden
through its panel since the mesh-preview change, so it reads the panel.
--probe-material-editor: 65 passed, 0 failed (was 50 + an exception).
A live probe drives the real viewport and a real bridge session
(--probe-bridge-material-live): an off-screen D3DImageHost loads an
archive, opens a mesh in a running bridge Blender and waits for an
object made there, wearing a material made there, to be pushed back.
It checks the scene, the renderer, a re-push with a changed image,
undo/redo, Save, a reload from disk and a pack, then restores every
file. 21 passed against Blender 5.1.2.

What it found:
- image resize went through Image.copy().scale(), which hands back the
  blank original of a painted or generated image - the texture arrived
  black. The addon now resamples the pixel array itself.
- a rebuilt mesh's parts carried no material hash, so a later material
  edit (or a texture rewritten by a push) could not find them to
  re-resolve. The hash and tint now ride with the part, as on a mesh
  loaded from disk.
- Blender keeps the hash of a material the library may have lost
  (creation undone, toolkit closed without Save). With pixels it is
  made again; without them the push is refused once and the ack tells
  the datablock to forget, so the next push arrives complete.
…g resources

A Blender-made material came out BLACK in game. The object was there and
the material was sound (same shader, flags and sampler states as 1888
stock materials) - the texture was not in a shape the game loads.

- From 256x256 up the game stores a texture split: the top level alone
  in a Mipmap entry, everything from half resolution down in the Texture
  entry with HasMIP=1 (6747 split textures surveyed, no exception; no
  stock mip-chained texture is wider than 128 on its short side). The
  encoder wrote a 1024x512 texture whole. DdsEncoder.Encode now returns
  the pair, and ArchiveTextureWriter writes both files and both entries.

Two packer bugs found while comparing the rebuilt archive with stock:

- TextureHandler charged a split texture only its own payload. Stock
  charges the Texture entry its payload PLUS its MIP companion's and
  leaves the Mipmap entry at zero (381 of 381 in italy.sds), so every
  Build under-reported the archive's video memory by the size of all its
  top levels - 6 MB for italy.
- PruneMissingEntries joined a rooted resource name ("/missions/...")
  with Path.Combine, looked for the file at the drive root, and unsaid a
  resource that was present. italy.sds lost its AudioSectors on Build.

--probe-bridge-material: 30 passed (split layout, the memory figure
against the files on disk, pruning with a rooted name).
The billboard was still black in game after its texture was stored
correctly. The material had been compared with stock on shader, flags,
sampler states and parameters - through a view that does not show every
field. Two were left at zero by the Default preset:

- Unk0: 128 on 1837 of the 1929 stock materials on that shader
- the diffuse sampler's TexType: 2 on 1947 of 1951 (0 on four)

Material_v57's Default preset now sets both, which also covers the
materials the glTF import creates. v58 (Definitive Edition) is left
alone: there is no DE install here to measure it against.

The probe compares a created material with the majority of stock
materials on its shader in these fields, so a preset that drifts from
the game's own records fails here instead of in game.
The previous commit did not build: the new stock-record check declared
'made', which the addon-container section of the same method already
uses. --probe-bridge-material: 25 passed.
A Blender-made material arrived with its Base Color image only. It now
also brings the image behind its Normal Map node and the one driving
its specular level, and becomes a normal-mapped game material.

Chosen from a survey of the stock library, not assumed:
- shader 5159568776351604322 (S000 + S001, parameter D013): 1209 stock
  materials, drawn on the P|N|T|UV0 declaration a pushed mesh already
  has. Its S001 is one combined texture - normal X and Y in red and
  green, the SPECULAR level in blue (stock "...NS" textures: red/green
  127 +- 10, blue 30..240), DXT1 like 97 of the 120 sampled.
- NormalSpecularPacker builds that texture from the two Blender images
  (either may be missing) and inverts green: Blender's normal maps have
  green up, the game's bitangent follows texture V, which runs down.
- MaterialPreset.DiffuseNormal creates the record in the commonest
  stock shape of that shader - Unk0, flags, both samplers' TexType and
  states, D013 - and the probe compares it field by field with the
  majority of the 1209.
- Blender's roughness and specular level become D013's power and level;
  the defaults (0.5 / 0.5) land on the commonest stock pair, 16 / 0.3.

A material that gains or loses its normal map needs another shader, so
it is replaced under the same name - the hash meshes refer to does not
move. The resolver now talks to an IAuthoredMaterialHost (create,
update, replace) instead of two callbacks; the application's host
records each on the undo history.

Also: binding a texture to a sampler sets its TexType to 2 (a slot
added in the Material Editor started at 0, which samples nothing in
game); two materials sharing an image share one texture file.

--probe-bridge-material: 33 passed. Driven live through the real
viewport against a windowless Blender: the billboard in italy took a
new material with all three maps and shows the relief in Render mode.
{
(byte[] texture, byte[]? topLevel) = DdsEncoder.Encode(rgba, width, height);
string file = ArchiveTextureWriter.PickName(dir, imageName, reuse, texture);
ArchiveTextureWriter.Write(dir, file, texture, topLevel);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Include texture bytes in the push undo transaction

Repainting an already-authored image overwrites its DDS/MIP files here, but the history only records material bindings and mesh changes. If the texture name and geometry stay unchanged, UpdateAuthored records nothing: I reproduced History.CanUndo == false after a same-binding update. Undoing a push therefore cannot restore the previous pixels, even when it restores the mesh or material. Snapshot the affected files and manifest entries in the push transaction, and restore/invalidate them on undo and redo.

Comment on lines +34 to +37
if (content != null && existing.Exists && existing.Length == content.Length
&& File.ReadAllBytes(existing.FullName).AsSpan().SequenceEqual(content))
{
return candidate;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Compare the top mip before reusing a texture name

For split textures, content contains only the lower mip chain. Different images can have identical lower mips while differing in MIP_*.dds: a 256x256 black/white checkerboard and solid gray reproduce this with the current encoder. Importing them as stone.png and stone.jpg returns the same stone.dds name, then overwrites the first material's top mip. Include the top-level bytes and companion-file presence in the equality check before sharing a name.

Comment on lines +126 to +130
if (MafiaMaterials.FindHashByName(name) is { } known)
{
info.Hash = Format(known);
Acknowledge(name, known);
continue;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Package reused authored textures in every owning archive

When objects from two archives share a new Blender material in one push, the first object creates the catalog entry and writes its texture into archive A. The second object takes this branch and never packages that dependency into archive B. I reproduced two successful resolutions with the DDS present only in A and only one touched archive. B cannot resolve that texture when loaded independently in the game. Ensure each destination archive receives the authored material's texture dependencies, including when the catalog/hash is already known and no new pixels were sent.

bool wasNormalMapped = current.Normal != null;
if (wasNormalMapped != updated.NormalMapped)
{
if (_host.Replace(hash, updated) is not { } replaced)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Preserve material identity when changing its shader

updated.Name comes from the current Blender datablock name, whereas ReplaceAuthored deletes the old material and creates one whose hash derives from that name. Rename a previously pushed shared material in Blender and add/remove its normal map: this replacement changes its hash and deletes the original catalog entry. Only slots in the current push get the new hash; other meshes retain the now-missing material. I reproduced the hash changing with the old entry absent. Preserve the existing catalog name/hash for shader replacement, or migrate all references as an explicit rename operation.

Comment on lines +174 to +175
string? diffuse = Write(document, dir, "d:" + info.DiffuseImage!.Block, info.DiffuseImage.Name, currentDiffuse,
diffusePixels, info.DiffuseImage.Width, info.DiffuseImage.Height, out reason);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Validate and stage all maps before overwriting existing files

The diffuse file is committed before normal/specular blocks are validated, and before the catalog update can succeed. An update with valid new diffuse pixels and an invalid normal block returns false but has already replaced the existing diffuse DDS; I reproduced this with an out-of-range normal block. Later map-write failures have the same partial-commit problem. The UI reports the object as skipped while its material's pixels have changed. Validate all inputs first and stage/rollback file and catalog changes so a refused update leaves the existing material intact.

// changed; and one rewritten under its old name changed no material, so the renderer has
// to be told to read the file again.
foreach (FileInfo archive in authored.TouchedArchives.Values) _host.Persistence.MarkArchiveModified(archive);
foreach ((ulong hash, string texture) in authored.Rewritten) _host.MaterialEditing.ReloadTexture(hash, texture);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Coalesce texture reloads before refreshing shared materials

Rewritten gets an entry for every material occurrence even when _written has already handled the same image. A push of 100 objects sharing one material produces 100 identical reload entries in my harness. Each call here invalidates/reloads the DDS and RefreshMeshesUsing scans and rebinds all matching meshes on the UI thread, giving quadratic rebind work for a shared material. Deduplicate texture invalidations and refresh each affected material once after all its textures have been invalidated.

{
if (!_cache.Remove(name, out Entry? entry)) return;
if (entry.Srv.Handle == _white.Handle) return; // a cached miss — nothing to keep alive
_retired[(nint)entry.Srv.Handle] = entry;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Release superseded mesh leases after a texture rebind

The new retirement path relies on old leases being returned, but GpuMesh.RebindPartTextures retains every previous lease until the mesh is disposed. Every repaint now leaves a distinct GPU texture alive for the entire time the mesh remains loaded. With a real GpuContext and GpuMesh, 20 invalidate/rebind cycles left 20 entries in _retired; they disappeared only on mesh disposal. This grows VRAM without a bound during painting, even after duplicate reloads are fixed. Track leases per part and release the superseded acquisitions once the part uses its replacement, preserving other meshes' references.

Comment on lines +194 to +195
new MeshPart(0, outIdx.Length,
part.DiffuseTexture, part.NormalTexture, part.SpecularTexture, part.MaterialHash),

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Carry the material tint into the mesh preview

The replacement MeshPart omits part.Tint, so its default is white even though SetMaterial supplied the material's color. Switching to mesh preview therefore loses the color of tint-based materials, such as untextured painted surfaces. Calling BuildFlatMesh with a red part reproduced an output part with a white tint. Pass part.Tint to the new part so sphere and mesh previews show the same material color.

Comment on lines +163 to +165
if node.type != 'NORMAL_MAP':
return None
return _image_behind(node.inputs.get("Color"))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Check the Normal Map node's coordinate space

Checking only node.type accepts object- and world-space normal maps, but the game packer interprets their channels as tangent-space XY and flips green. These maps therefore export successfully with incorrect lighting. An OBJECT-space node is accepted by this helper in an isolated test. Blender exposes these choices through ShaderNodeNormalMap.space, verified through Context7. Require space == 'TANGENT' and report unsupported spaces, or convert them before packing.

low = np.floor(position)
weight = (position - low).astype(np.float32)
first = np.clip(low.astype(np.int64), 0, current - 1)
second = np.clip(first + 1, 0, current - 1)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P2] Clamp both interpolation indices independently

During upsampling, the first texel center lies before source texel zero, so low is -1. Clamping first before computing second turns the intended edge pair (0, 0) into (0, 1), blending heavily with the next texel. Resampling a row [0, 1, 0] from width 3 to 4 produces 0.875 at its left edge instead of 0. This corrupts the first row/column of images rounded up to a power of two. Compute second by clamping low + 1, independently of the already-clamped first.

…shared across archives

Review of mafia2online#4, all ten findings.

Texture files are part of the push's undo. A repaint under the same name
changes no binding, so the catalog recorded nothing and Ctrl+Z could not
bring the old picture back. The resolver now journals every file it
writes (what the name held before, what it holds now) and the push
carries a TextureFilesEdit in its one history entry: undo restores the
old files - or removes a texture the push introduced, manifest entries
included - and redo puts the new ones back.

Nothing is written before everything is checked. The diffuse texture was
on disk before the normal map had been looked at, and before the library
had accepted the material. All images are now read, validated and packed
first; what is written before a later file or the library refuses is
rolled back from the same journal.

A split texture is compared as two files. PickName compared only the
entry, which for a texture of 256 and up starts at half resolution: a
fine checkerboard and the grey it averages to are byte-identical there
(reproduced), so the second picture took the first one's name and its
write replaced the first one's top level. The MIP_ companion - and
whether there is one - is now part of the comparison.

A material shared by two archives is packed into both. The second object
of a push binds the new material by name, and an unchanged material
arrives with no pixels; either way an archive that had never held its
textures got none. The files are now copied from the archive that has
them (only for materials this bridge created - a stock material bound by
name copies nothing).

A renamed material keeps its hash across a shader change. Gaining or
losing a normal map deletes and recreates the record, and it was
recreated under the Blender datablock's current name - a new hash, with
every other mesh left pointing at the old one. It is rebuilt under the
name the library knows it by.

Reloads are coalesced. A push of a hundred objects sharing a material
asked for a hundred invalidate-and-rebind passes over every loaded mesh;
each texture is now invalidated once and each material that names one
rebinds once (ReloadTextureFiles).

A rebound part returns the textures it held. GpuMesh kept every lease
until dispose, so the library's retired copy of each repainted texture
stayed on the GPU for as long as the mesh was loaded. Leases are kept per
part and the superseded ones released once the part holds the new ones.

The mesh preview keeps the material's tint.

Add-on: a Normal Map node in Object or World space is no longer exported
as if it were tangent space - the material is refused by name with what
to change - and the resampler clamps its two neighbours independently:
upsampling [0, 1, 0] to four texels gave 0.875 at the left edge, now 0.

--probe-bridge-material 58/58 on eastside (30 new checks: each case
above, including twenty repaints of a texture a live GpuMesh draws
leaving nothing retired), --probe-material-editor 65/65,
--probe-bridge-rebuild 17/17, --probe-mcp 66/66. The undo entry itself
was checked at the file level (restore before/after), not by pressing
Ctrl+Z in the editor, and the two add-on changes were not run inside
Blender: the resampler was checked on the reviewer's numbers outside it.
838c69a made the texture files of a push part of its undo and put a
refused material's files back. Read again before it went up:

Undo removed a texture the push had introduced. The material that names
it is created by an edit of its own and outlives the push's entry, and
Blender - told the push arrived - sends pixels again only when the image
changes: after one Ctrl+Z the texture was in no archive, and the next
push put the material on the object with nothing to draw it from. The
undo entry takes back pictures that were REPLACED; an introduced one
stays with its material.

Undo and redo did not put the archives back on the build list. Push,
Build, Ctrl+Z: the folder had the old picture, the game's archive the new
one, and Build answered that there was nothing to build.

A push that only repainted a texture was dropped from the history by the
first unload of any district: its entry names no objects, and 'all of its
objects have left the scene' is true of none.

A write that failed was remembered as done. The name was noted before the
write; the next object of the push sharing the image was handed it as
success and its material named a file that was never made. Noted after.

A texture half written - the entry replaced and its top level, or the
manifest, not - was caught with nothing on record to put it back from. It
is put back before the failure is passed on, and neither that nor
AtomicFile leaves its .tmp beside the target any more.

An object is refused before any of its materials is touched: every slot
is checked first for what can be refused without writing (normal map
space, images that did not arrive whole). A bad second slot used to be
found after the first slot's pictures had been replaced. A texture that
cannot be copied into the object's archive refuses the object as well.

--probe-bridge-material 61/61 (3 new: the two-slot object; a texture held
open refuses both objects sharing the image and leaves the picture, the
manifest and the folder as they were). --probe-material-editor 65/65.
The undo entry itself (introduced texture kept, archives re-enlisted, the
entry surviving an unload) is not covered by a probe - it lives behind
the editor's history.
… undo entry

Two things 4034b0f left. The check of every slot before any is written did
not include a material that would have to be made and arrives with no
picture to make it from - new, or one Blender remembers and the library
no longer has: found at its own slot, after an earlier slot's texture had
been replaced. Both are refused in the pre-pass now, in the words they
had. And a push whose only texture changes were introductions made an
undo entry with nothing in it - a Ctrl+Z that did nothing and cost the
redo branch.

--probe-bridge-material 62/62 (1 new: a repaint first, a new material
with no image second - refused, the first left as it was).
raighen added a commit to raighen/illusion-toolkit that referenced this pull request Oct 5, 2026
… is sampled linear

Review of mafia2online#5, the two findings outside the mirror.

The material editor's preview still alpha-tested a translucent material.
SphereMesh forwards a part's Blended flag, but the part the preview
builds for a material carried only its tint, and the mesh-shape preview
dropped the flag again - so a uniform 0.3-alpha pane vanished from the
ball while the scene drew it blended. The flag is read from the catalog
with the tint and kept in both preview shapes.

Add-on: a colour image used as an alpha mask was read through
image_rgba8, which turns a picture into what an albedo texture stores
(sRGB bytes), and the luminance of those encoded values was written as
coverage. A float mask at linear 0.25 came out as 137/255 instead of 64 -
enough to carry a cut-out across the 0.5 it is tested against. The mask
is now read in the values the shader works with (_shader_pixels): float
buffers as they are, 8-bit sRGB linearized as the Image Texture node
does, Non-Color data and the alpha channel as stored.

Run in a background Blender on the add-on as it stands in this branch:
linear 0.25 float mask -> 64, 8-bit sRGB 0.5 -> 55, Non-Color 0.5 -> 128,
alpha channel 0.3 -> 77; the same run covers the two add-on fixes merged
from mafia2online#4 (Normal Map node space, resampler edge). The preview change was
not looked at in the material editor window.
@raighen

raighen commented Oct 5, 2026

Copy link
Copy Markdown
Contributor Author

All ten findings are fixed on top of ecf8219, no force-push: 838c69a, 4034b0f, 902583d.

  • Material identity: a shader change rebuilds the material under the name the library knows, the hash stays.
  • Shared textures: copied into every archive that binds the material.
  • Staging: everything is validated before the first write, a partial write is rolled back.
  • Undo: texture files are part of the push's history entry.
  • Top mip: the MIP_ companion is part of the name comparison.
  • Leases: superseded ones are released on rebind.
  • Normal Map space: Object and World are refused.
  • Resampler: both indices are clamped.
  • Reloads: one per texture.
  • Preview: the tint is carried.

--probe-bridge-material 62/62, --probe-material-editor 65/65. #3, #5, #6, #7 and #9 are stacked on this branch, #8 is separate.

A city hall interior was built with this stack, M2O Client does not crash. If there are more remarks, I am ready to hear them.

@raighen
raighen requested a review from Segfaultd October 5, 2026 23:46
mafia2online#3 was stacked on this branch and was merged as one commit, so main already has every
change made here and git could no longer line the two histories up. The merge takes
main's tree as it is: nothing of this branch is left to add.
Segfaultd pushed a commit that referenced this pull request Oct 7, 2026
…ollows imported scenery (#6)

* feat: preview a material on the context mesh slot in the Material Editor

Local work found uncommitted in the working copy (base a55dc22, v0.3.1):
sphere/mesh preview toggle, the slot label on the assign panel, and the
SDK pin moved to 10.0.302.

* feat: turn a material made in Blender into a game material on push

A push used to refuse any slot whose material had not come from the
toolkit. A Blender-made material now travels with the image wired into
its Principled Base Color and becomes a real game material:

- addon: sends the image as RGBA8 (top-down, resampled to powers of two)
  once per push however many slots use it; the ack stamps the hash back
  on the datablock so later pushes send pixels only when they changed
- DdsEncoder: DXT1 with a full MIP chain and the stock header, written
  as a single Texture entry (HasMIP 0) - the shape 2809 stock city
  textures ship in, so no companion Mipmap entry is needed
- AuthoredMaterialResolver: fills in the hash before the mesh path runs;
  binds by name when the game already has the material, otherwise writes
  the texture into the object's archive (file + manifest) and creates a
  default-preset material with it in S000 (one undo entry)
- a re-push with new pixels rewrites the texture in place and the
  renderer reloads it (TextureLibrary.Invalidate)

Probe: --probe-bridge-material [district] [push.ilx] - encoder, resolver,
re-push, bind-by-name, refusal, and optionally a container written by
the addon itself.

* fix: stop the Material Editor throwing while its XAML loads

The Sphere toggle is checked in markup, so its Checked handler ran before
the Mesh toggle and the preview existed and the window died with a
NullReferenceException on open. The handler now waits for both.

The probe read the assign BUTTON's visibility; the button is hidden
through its panel since the mesh-preview change, so it reads the panel.
--probe-material-editor: 65 passed, 0 failed (was 50 + an exception).

* fix: live-test the Blender material push and close what it found

A live probe drives the real viewport and a real bridge session
(--probe-bridge-material-live): an off-screen D3DImageHost loads an
archive, opens a mesh in a running bridge Blender and waits for an
object made there, wearing a material made there, to be pushed back.
It checks the scene, the renderer, a re-push with a changed image,
undo/redo, Save, a reload from disk and a pack, then restores every
file. 21 passed against Blender 5.1.2.

What it found:
- image resize went through Image.copy().scale(), which hands back the
  blank original of a painted or generated image - the texture arrived
  black. The addon now resamples the pixel array itself.
- a rebuilt mesh's parts carried no material hash, so a later material
  edit (or a texture rewritten by a push) could not find them to
  re-resolve. The hash and tint now ride with the part, as on a mesh
  loaded from disk.
- Blender keeps the hash of a material the library may have lost
  (creation undone, toolkit closed without Save). With pixels it is
  made again; without them the push is refused once and the ack tells
  the datablock to forget, so the next push arrives complete.

* fix: store large textures the way the game does, and stop Build losing resources

A Blender-made material came out BLACK in game. The object was there and
the material was sound (same shader, flags and sampler states as 1888
stock materials) - the texture was not in a shape the game loads.

- From 256x256 up the game stores a texture split: the top level alone
  in a Mipmap entry, everything from half resolution down in the Texture
  entry with HasMIP=1 (6747 split textures surveyed, no exception; no
  stock mip-chained texture is wider than 128 on its short side). The
  encoder wrote a 1024x512 texture whole. DdsEncoder.Encode now returns
  the pair, and ArchiveTextureWriter writes both files and both entries.

Two packer bugs found while comparing the rebuilt archive with stock:

- TextureHandler charged a split texture only its own payload. Stock
  charges the Texture entry its payload PLUS its MIP companion's and
  leaves the Mipmap entry at zero (381 of 381 in italy.sds), so every
  Build under-reported the archive's video memory by the size of all its
  top levels - 6 MB for italy.
- PruneMissingEntries joined a rooted resource name ("/missions/...")
  with Path.Combine, looked for the file at the drive root, and unsaid a
  resource that was present. italy.sds lost its AudioSectors on Build.

--probe-bridge-material: 30 passed (split layout, the memory figure
against the files on disk, pruning with a rooted name).

* fix: give a created material the two fields the stock ones carry

The billboard was still black in game after its texture was stored
correctly. The material had been compared with stock on shader, flags,
sampler states and parameters - through a view that does not show every
field. Two were left at zero by the Default preset:

- Unk0: 128 on 1837 of the 1929 stock materials on that shader
- the diffuse sampler's TexType: 2 on 1947 of 1951 (0 on four)

Material_v57's Default preset now sets both, which also covers the
materials the glTF import creates. v58 (Definitive Edition) is left
alone: there is no DE install here to measure it against.

The probe compares a created material with the majority of stock
materials on its shader in these fields, so a preset that drifts from
the game's own records fails here instead of in game.

* fix: rename a probe local that clashed with one further down

The previous commit did not build: the new stock-record check declared
'made', which the addon-container section of the same method already
uses. --probe-bridge-material: 25 passed.

* feat: carry a Blender material's normal and specular maps into the game

A Blender-made material arrived with its Base Color image only. It now
also brings the image behind its Normal Map node and the one driving
its specular level, and becomes a normal-mapped game material.

Chosen from a survey of the stock library, not assumed:
- shader 5159568776351604322 (S000 + S001, parameter D013): 1209 stock
  materials, drawn on the P|N|T|UV0 declaration a pushed mesh already
  has. Its S001 is one combined texture - normal X and Y in red and
  green, the SPECULAR level in blue (stock "...NS" textures: red/green
  127 +- 10, blue 30..240), DXT1 like 97 of the 120 sampled.
- NormalSpecularPacker builds that texture from the two Blender images
  (either may be missing) and inverts green: Blender's normal maps have
  green up, the game's bitangent follows texture V, which runs down.
- MaterialPreset.DiffuseNormal creates the record in the commonest
  stock shape of that shader - Unk0, flags, both samplers' TexType and
  states, D013 - and the probe compares it field by field with the
  majority of the 1209.
- Blender's roughness and specular level become D013's power and level;
  the defaults (0.5 / 0.5) land on the commonest stock pair, 16 / 0.3.

A material that gains or loses its normal map needs another shader, so
it is replaced under the same name - the hash meshes refer to does not
move. The resolver now talks to an IAuthoredMaterialHost (create,
update, replace) instead of two callbacks; the application's host
records each on the undo history.

Also: binding a texture to a sampler sets its TexType to 2 (a slot
added in the Material Editor started at 0, which samples nothing in
game); two materials sharing an image share one texture file.

--probe-bridge-material: 33 passed. Driven live through the real
viewport against a windowless Blender: the billboard in italy took a
new material with all three maps and shows the relief in Render mode.

* style: dotnet format the preview viewport's mesh builder

* feat(mcp): tools that drive the running map editor

The MCP server could read game files but not touch the editor, so a client
working on a scene had to fall back on UI automation for everything the
window does. These tools close that gap:

- editor_status / editor_list_areas / editor_open_area (opens the editor
  from the launcher and waits for the area to finish loading)
- scene_find (by name, kind, or a world box tested against a mesh's
  triangles rather than its bounds) and scene_select
- blender_open / blender_end, and editor_notices for what a push reported
- editor_save / editor_build, object_move, scene_delete_selected,
  editor_undo / editor_redo
- camera_get / camera_look_at / camera_set / camera_frame_selection,
  view_set (shading mode and layers) and viewport_screenshot

Illusion.Mcp gets the IEditorSession seam; the application implements it
over the open MainWindow. Supporting changes: ViewportControl.CaptureFrame,
LookAt and LookFrom, DistrictStreamer.IsBusy, LauncherWindow.OpenMapEditor,
and a notice log the banner feeds.

* feat(mcp): blender_push, and scene_select with no names clears the selection

blender_push sends the bridge's request_push, waits for the push to land
and returns what the editor reported, so a client that edited the session's
objects in Blender gets the outcome in the same call instead of polling the
notice log. An empty scene_select clears the selection: its outline is
drawn through walls and would otherwise sit in every screenshot.

* fix(bridge): refuse a pushed mesh over 65535 vertices instead of writing 32-bit indices

A rebuild that came to more than 65535 split vertices switched the level's
index buffer to 32-bit. The toolkit's viewport draws that; the game does
not — an interior of 65 981 vertices came up as triangles smeared across
the district, textures flickering, while the editor showed it whole.

The push is now refused with the vertex count, so the modeller can split
the object. A rebuild always writes 16-bit indices, and a level that still
carries a wide buffer from before is rebuilt on its next push whatever
that push changed, which is what puts it right.

scene_find reports each mesh's vertex and triangle count, so the size of
an object can be checked before it is built into an archive.

* feat: bring a light into a district that has none — actor import across archives

A city district ships with no lights of its own: its interiors are lit by
LightEntity actors, and the only way the editor could make an actor was to
copy one already in the pack. ActorsFile.Import copies an actor out of
another archive's pack, with its own copy of the behaviour row it points
at; the editor wraps it as one undoable edit and gives it a row in the
tree, creating the entity type's section when the district never had one.
Only an actor that places no object of its own scene can travel — a light,
a sound — and the copy is linked to a frame named after itself, which is
the frame such an entity makes.

A light keeps its transform twice: in the actor record and at the head of
its behaviour blob (every shipped light's x appears exactly twice). The
record is what the editor moves, so the pack now carries the translation
over into the blob when it is written; an untouched pack still writes byte
for byte.

MCP: actor_import, scene_duplicate_selected, object_properties and
object_set_property (the property panel as data, undoable writes), and
decode_actors can list each actor's behaviour fields.

* Keep a light's inverse matrix and clip box in step with where it stands

A light's behaviour blob ends with the inverse of its world matrix, and the
game works the light out through it. A light imported from another archive
(or moved in the editor) kept the old inverse: every field read correctly,
the light stood in the right place, and it lit nothing.

SyncLightFrame now rewrites the inverse whenever it is out of step with the
head matrix, carries the clip box along by the distance the light moved, and
runs on import as well as on write. Untouched packs still write byte for
byte: shipped lights are within 1e-4 of a true inverse and are left alone.

* Show and edit a mesh's draw distance in the property panel

Each level of detail carries the distance at which it stops being drawn,
stored squared. It is the one cost control a static object has - the game
culls by distance and view cone only - and an object made through the
Blender bridge starts at a million metres. The panel (and so the MCP
object_set_property) now lists one draw distance per level, in metres.

* fix: touch a light's row only when the light has moved

The light sync rewrote any inverse matrix that was out of step with the
head matrix. The install has 72 such rows that shipped that way (three
Joe's Adventures archives carry zeroes or noise there), so re-saving those
untouched packs changed them: --probe-act-relayout, --probe-actor-props and
--probe-native-misc fell to 1047/1050.

Now a light whose record and head translation agree is left alone, and a
moved light gets a new inverse only if the one it had was a true inverse.
The clip box and inverse are also written to the row's named fields: on
write the core pokes every named field back over the blob, which put the
imported light's old box straight back.

--probe-act-relayout gains the import: a light from one archive into a
pack of another, checking its own row, matrix, inverse, clip box, the
writer round trip and a refused duplicate name. 1050/1050 packs re-save
byte for byte again.

* docs: the editor tools, the Blender material push and their limits

README: the MCP section lists the 25 editor tools and says how they relate
to the editor's own undo, save and build; the bridge section covers pushed
materials and the 65535-vertex refusal; the property panel mentions draw
distances; Known limits gains opaque-only pushed materials and undrawn light
actors. --probe-mcp now expects the editor tools as well.

* feat: carry a Blender material's alpha into the game - cut-out and translucent

A material made in Blender was always opaque: its diffuse went to DXT1 and
its flags were the plain ones. Now whatever feeds the Principled BSDF's
Alpha - the image's own alpha, a mask image, or a value below 1 - is written
into the diffuse texture, which is then stored as DXT5, and the material's
render method picks the mode: Blended is a translucent surface, anything
else a cut-out.

The game has no separate shader for either on the shaders the bridge
creates materials for; the flag word decides. Measured over default.mtl on
the diffuse shader: 239 materials set Alpha and keep writing depth (fences,
signs, foliage), 323 set Disable_ZWriting on DXT5/DXT3 textures (overlays),
11 carry both. A cut-out gets Alpha and keeps CastShadows; a blend gets
Disable_ZWriting and drops CastShadows; every other bit is left alone.

- DdsEncoder: DXT5 (interpolated alpha block + the DXT1 colour block), with
  the header stock DXT5 files carry.
- MafiaMaterialCatalog: GetFlags / SetFlags; the editor's SetFlags is an
  undoable edit.
- The addon sends alphaMode with the material and puts the alpha it means
  into the image; an opaque material keeps its old signature, so nothing is
  resent for nothing.
- --probe-bridge-material: 40/0, with the encoder's alpha round trip, the
  flag words, and one material taken through cut-out, blend and back.

* feat(viewport): draw alpha-blended materials blended

The mesh shader alpha-tested every textured material at one half. That is
right for a cut-out and wrong for glass: a pane whose alpha is 0.3 was
clipped away entirely, so a translucent material - a stock one or one
pushed from Blender - was simply not there in the viewport.

A part now knows whether its material blends (Disable_ZWriting in the flag
word), and such parts are drawn in a pass of their own after the opaque
one: depth-tested, never depth-written, standard alpha blend, no clip. The
flag follows a material edit the same way the textures and the tint do.
Unsorted on purpose - a pane seen through a pane may composite in the wrong
order. Instanced props keep the alpha test.

* feat: mirror a district's edits into its winter archive

A district ships twice and an edit made to <name>.sds is simply absent
from <name>_z.sds. For most districts the two are one scene: measured on
uppertown, every shared resource (buffer pools, name table, actors,
collisions, item descriptions, prefab) is byte for byte the same, and the
frame resource has the same length and differs in 1740 places - each a
material hash swapped for its ^zima counterpart, 82 pairs. Textures aside,
nothing else differs.

SeasonMirror writes the summer working copy over the winter one with each
object's winter materials taken from the winter object of the same name
(and occurrence - names repeat), copies the shared files that differ, adds
the pools summer gained with their manifest entries, and adds the textures
the result names that winter lacks. The caller queues the winter archive
for Build.

Not every district is such a pair: 13 of 23 are, ten have a winter scene of
their own. The pair is therefore judged first on the archives as shipped
(each one's oldest backup, or the archive itself if never built), and a
district that fails is refused rather than overwritten.

- MCP: editor_mirror_winter.
- --probe-season-mirror: 11/0 on scratch folders built from one working
  copy, so it does not depend on what the modder did to the install - an
  untouched pair is left alone, a missing object arrives and winter is
  byte for byte whole again, a stranger is refused untouched; reports which
  of the install's pairs are twins.

* feat: carry objects from one archive into another

A district can now be furnished from the rest of the game: a door from a
shop, a chair or a plant from an interior, brought into the loaded area as
one undoable edit (MCP object_import).

- FrameTransplant copies a frame subtree across resources: frames re-homed
  (FrameEntry.MoveTo), geometry and material blocks deep-copied once per
  shared block, every buffer they draw from copied into the district's
  own pools under fresh names. Links inside the subtree follow the copies;
  the root becomes a prototype (an actor places it) or scenery anchored to
  the district's main scene, on the name table, standing where it is put.
- ActorsFile.Import takes an actor that places an object once that object
  has been carried, links it by the copy's name through a scene reference
  of its own, and can turn it.
- ArchiveCarry brings what the scene does not hold: the textures the
  materials name, the item descriptions the collision frames name, and the
  prefab entry the definition names (PrefabFile.Adopt, which keeps the
  container's size header right - the core writes it as it is handed it).
  Keys travel unchanged; what the district already has is shared.
- ObjectImportController shows the copy (tree rows, GPU meshes, actor row)
  and undoes it as one edit.
- A frame named by hash alone (weapon parts) kept a zero hash when copied:
  the copy constructor rebuilt the name from its empty string. Fixed for
  the duplicator and the prototype cloner too.

--probe-object-transplant: 30/0 - a door, a prop an actor places and a
piece of scenery go from shops/harry.sds into a scratch copy of a district,
keep their shape frame for frame and their buffers byte for byte, survive a
save and a reload (actors resolve, meshes decode from the district's pools,
prefab and item descriptions in the manifest), and an undo leaves the scene
byte for byte as it was.

* feat: a Props tab to drag stock objects into a district

The map editor's property rail gets a Props tab: the stock game's doors,
seating, tables, beds, storage, plants, lamps and decor, one card per
object with a picture. Drag a card onto the viewport to put the object
where it lands (on the scene's surface under the cursor), or double-click
it to put it in front of the camera.

- PropCatalog finds them in the extracted interiors and districts: every
  actor that places an object of its archive (door, physics prop, framed
  furniture), and scenery whose name says what it is and whose size is a
  piece of furniture. One card per object across archives; interiors are
  read first, so a card points at the stock source. The scan (~6 s) is
  remembered per archive and redone only for what changed.
- PropThumbnailRenderer draws each card's picture offscreen with the
  object's own textures and keeps it on disk.
- Scenery now brings collision: the source's hulls that stand inside its
  footprint, re-placed by the same move (CollisionCarry), or one cooked
  from its triangles when it had none - small props usually have none.
- A door carried out of its building gets ActorActionsEnabled: a shop's
  front door ships with the player's actions off, its shop script turns
  them on, and nothing does that where it lands.
- The import core moved from the MCP session into ObjectImportController,
  shared by object_import and the tab. A card from the loaded district is
  duplicated in place rather than refused.
- RefManager hands out ids atomically: the catalog parses scenes in the
  background while the editor loads districts.

--probe-prop-catalog: 7/0 - 2747 props in 6.3 s, 52 ms remembered, known
doors and chairs on their shelves, each shelf's first card gets a picture.

* feat: simplified hulls for imported scenery, and a props column

- A hull cooked for scenery that had none of its own is now its convex
  hull by default (ConvexHull: incremental, points snapped to a grid a
  fortieth of the object's size) - a coat rack goes from 1474 collision
  triangles to 22. Also a box (12 triangles) or every render triangle,
  chosen per import: object_import's 'collision' (auto / convex / box /
  mesh / none) and a chooser in the props panel. A flat thing has no
  convex hull and gets a box.
- The props panel moves out of the property rail into a column of its own
  left of the viewport: as tall as the window, sized with a splitter,
  shown and hidden by a Props button on the toolbar; the viewport's tool
  shelf moves with the viewport and is no longer covered.
- Textures an import carries are written down beside the working copy
  (illusion_carried.json, outside the manifest) and each save sweeps the
  ones no material of the scene names any more - an undone import, or one
  lost with a scene closed unsaved, no longer leaves megabytes behind.

--probe-object-transplant: 33/0 (+ the hull of a cube is the cube, a stock
model's hull holds all its vertices and faces out, a flat thing gets a box).

* feat: an imported object's collision moves and is deleted with it

Nothing in an archive ties a static object to its collision - the hull is a placement that happens to stand where the object stands. For scenery the toolkit carried in, the tie is now written down beside the working copy (ImportLinks, illusion_links.json, outside the manifest): the object's name and the hashes of the hulls it was given. A gizmo drag of the object drags those placements along (a scale mints a scaled hull as it does for any placement), and Delete takes them with it; a Delete that went through several editors is one Ctrl+Z (EditHistory.SquashSince). Stock objects are untouched: nothing ties them, and guessing by position would take a building's hull along with a bench.

* feat: a duplicate of an imported object gets copies of its collision

Duplicating scenery the toolkit carried in also places copies of the hulls it was given, where the copy stands, links them to the copy under its own name, and the whole duplicate is one Ctrl+Z. Linked hulls are now claimed one to one across the scene, nearest first and ties to the name that sorts first: a copy stands on top of its original with the very same hull, and dragging it used to drag the original's hull away.

* feat: save drops geometry nothing draws; a re-import shares geometry

- Save writes the buffer pools without the buffers no geometry block
  draws from - left behind by a deleted object, a sanitised block, a
  rebuilt mesh. Measured: none of the 130 archives as shipped carries one
  (--probe-geometry-sweep), the install's working copies had ~0.5 MB.
  Only the files lose them: memory keeps them, so an undo after a save
  brings the object back and the next save writes them again
  (SdsGeometrySaver.SavePools tracks what each pool file holds).
- Importing an object again draws from the buffers - and, when every level
  is there, the geometry block - its earlier import brought, the way the
  shipped scenes put many copies of one chair on one mesh. Where geometry
  went is remembered per source archive beside the working copy
  (ImportGeometry, illusion_geometry.json); a remembered buffer the scene
  no longer has is simply copied again.
- The bridge probes snapshotted only the pool they edited; a save now may
  rewrite others, so they snapshot and restore every pool.

--probe-object-transplant: 38/0 (+ imported twice shares, the memory
survives on disk, undoing copies keeps the first, a save leaves dead
buffers out of the files and writes them back when drawn again).

* perf(bridge): read a level's index width without decoding the mesh

Review of #3. HasWideIndices decoded the whole level - every vertex
channel, the index array and, on a model, the skin - to read one field of
its index buffer, on every push, ahead of a path that decodes the mesh
again. It now clamps the level and looks at the index buffer directly.

--probe-bridge-rebuild 17/17, --probe-bridge-vertex 2/2,
--probe-bridge-resplit 6/6.

* fix(mcp): editor tools say no when the editor does, and find crash copies from the table

Review of #3, the findings in the MCP session.

editor_open_area dropped unsaved edits. Changing the area or the season
reloads the scene, which clears the edited frames, the unsaved list and
the undo history, and the tool only checked for a Blender session. A load
that would replace a scene with unsaved edits is now refused unless the
caller passes discardUnsavedEdits=true; asking for the area and season
already shown stays a no-op.

editor_save and editor_build reported success after a save that did not
complete. The viewport's save does not throw for a material library that
would not write or a car's working copy that was refused - it posts a
notice and carries on - so the tool had nothing to return but success, and
a build went on to pack archives whose materials were not on disk.
D3DImageHost.SaveEditsReport hands back what was left unsaved;
editor_save answers success=false with notSaved, and a build whose save
did not complete packs nothing and says why.

object_set_property could not rename. Base.Name is offered as an editable
HashName and the parser had no case for it. It has one now, with the
property panel's rule (an empty name is refused), and a rename to the name
the object already has is not committed as an edit.

Numeric properties took NaN and infinity. float.TryParse accepts "NaN",
"Infinity" and "1e100" (as infinity); a float and each component of a
vector now have to be finite.

scene_find did not see crash copies until their row had been expanded in
the tree, and a box query never returned one. They are now searched from
the placement table: by the copy's own label or its prop's name, and by
the prop's triangles at the copy's matrix. Only the copies returned get a
tree node.

--probe-editor-tools (new, no game install needed) drives the real tool
methods against a scripted editor and the parser directly: 23 checks.
Checked live in the editor on uppertown: 5 lamp copies found with no row
expanded, 288 triangles of one in a box round it, none 400 m above it; a
moved mesh blocks a load of another area and of the other season, the
same area is a no-op, discardUnsavedEdits=true loads. A failing material
write was not reproduced live - that path is covered by the scripted
editor only.

* fix(mirror): the winter mirror decides against the pair as shipped, not against the last mirror

Review of #5, the four findings in SeasonMirror.

Twins were told by size. Two archives passed as "one scene shipped twice"
when their shared pools were equal and their scenes the same length - and
a winter scene with one object moved 50 units is the same length. The
scenes are now parsed, winter's material hashes replaced by summer's, and
the two written back out: anything left that differs (a transform, a
parent link, a draw distance) means winter is a scene of its own and the
mirror is refused. The 13 stock twins still read as twins and the other
ten as not.

Everything else follows from reading that pair once (SeasonPair) and
deciding against it, where the mirror used to ask the winter WORKING COPY
what winter wore - which answers with whatever the previous mirror left:

- A slot the modder re-pointed in summer got its old winter material
  back. A summer material no object of that name wore when the pair
  shipped is now carried into winter as it is (Report.Reassigned).

- Namesakes were matched by position among objects of the same name, so
  deleting the first handed its materials to the second. The season's
  substitution is now looked up by name and SUMMER MATERIAL; position is
  only used when one summer material stands for two winter ones under one
  name, and only while the number of namesakes is what it shipped as.
  Otherwise the object keeps summer's material and is counted
  (Report.Ambiguous, raised as an error notice) rather than dressed in
  another object's snow.

- A texture an earlier mirror had brought was never refreshed: the bridge
  repaints an authored texture in place, and "winter has a file of that
  name" ended the matter. A file winter did not SHIP with is now compared
  and rewritten, its MIP_ companion with it (and removed when the new
  picture has none); a file winter shipped with is the season's own and
  is left alone.

Mirror() also refuses a winter folder that is not made of the pair's
meshes before writing anything, and editor_mirror_winter stops when the
save it starts with did not complete. Its result names slotsReassigned
and objectsAmbiguous in place of objectsReshaped.

--probe-season-mirror builds its own namesakes and runs every case above
on scratch copies: 23/23 on uppertown and oysterbay, 22/22 on italy,
21/21 on greenfield (not a twin, so the shipped-texture checks do not
apply).

* fix(bridge): a material push that can be undone, refused cleanly and shared across archives

Review of #4, all ten findings.

Texture files are part of the push's undo. A repaint under the same name
changes no binding, so the catalog recorded nothing and Ctrl+Z could not
bring the old picture back. The resolver now journals every file it
writes (what the name held before, what it holds now) and the push
carries a TextureFilesEdit in its one history entry: undo restores the
old files - or removes a texture the push introduced, manifest entries
included - and redo puts the new ones back.

Nothing is written before everything is checked. The diffuse texture was
on disk before the normal map had been looked at, and before the library
had accepted the material. All images are now read, validated and packed
first; what is written before a later file or the library refuses is
rolled back from the same journal.

A split texture is compared as two files. PickName compared only the
entry, which for a texture of 256 and up starts at half resolution: a
fine checkerboard and the grey it averages to are byte-identical there
(reproduced), so the second picture took the first one's name and its
write replaced the first one's top level. The MIP_ companion - and
whether there is one - is now part of the comparison.

A material shared by two archives is packed into both. The second object
of a push binds the new material by name, and an unchanged material
arrives with no pixels; either way an archive that had never held its
textures got none. The files are now copied from the archive that has
them (only for materials this bridge created - a stock material bound by
name copies nothing).

A renamed material keeps its hash across a shader change. Gaining or
losing a normal map deletes and recreates the record, and it was
recreated under the Blender datablock's current name - a new hash, with
every other mesh left pointing at the old one. It is rebuilt under the
name the library knows it by.

Reloads are coalesced. A push of a hundred objects sharing a material
asked for a hundred invalidate-and-rebind passes over every loaded mesh;
each texture is now invalidated once and each material that names one
rebinds once (ReloadTextureFiles).

A rebound part returns the textures it held. GpuMesh kept every lease
until dispose, so the library's retired copy of each repainted texture
stayed on the GPU for as long as the mesh was loaded. Leases are kept per
part and the superseded ones released once the part holds the new ones.

The mesh preview keeps the material's tint.

Add-on: a Normal Map node in Object or World space is no longer exported
as if it were tangent space - the material is refused by name with what
to change - and the resampler clamps its two neighbours independently:
upsampling [0, 1, 0] to four texels gave 0.875 at the left edge, now 0.

--probe-bridge-material 58/58 on eastside (30 new checks: each case
above, including twenty repaints of a texture a live GpuMesh draws
leaving nothing retired), --probe-material-editor 65/65,
--probe-bridge-rebuild 17/17, --probe-mcp 66/66. The undo entry itself
was checked at the file level (restore before/after), not by pressing
Ctrl+Z in the editor, and the two add-on changes were not run inside
Blender: the resampler was checked on the reviewer's numbers outside it.

* fix: the material preview blends what the scene blends; an alpha mask is sampled linear

Review of #5, the two findings outside the mirror.

The material editor's preview still alpha-tested a translucent material.
SphereMesh forwards a part's Blended flag, but the part the preview
builds for a material carried only its tint, and the mesh-shape preview
dropped the flag again - so a uniform 0.3-alpha pane vanished from the
ball while the scene drew it blended. The flag is read from the catalog
with the tint and kept in both preview shapes.

Add-on: a colour image used as an alpha mask was read through
image_rgba8, which turns a picture into what an albedo texture stores
(sRGB bytes), and the luminance of those encoded values was written as
coverage. A float mask at linear 0.25 came out as 137/255 instead of 64 -
enough to carry a cut-out across the 0.5 it is tested against. The mask
is now read in the values the shader works with (_shader_pixels): float
buffers as they are, 8-bit sRGB linearized as the Image Texture node
does, Non-Color data and the alpha channel as stored.

Run in a background Blender on the add-on as it stands in this branch:
linear 0.25 float mask -> 64, 8-bit sRGB 0.5 -> 55, Non-Color 0.5 -> 128,
alpha channel 0.3 -> 77; the same run covers the two add-on fixes merged
from #4 (Normal Map node space, resampler edge). The preview change was
not looked at in the material editor window.

* fix(import): a failed import leaves nothing behind; redo gets its shared block and its textures back

Found reviewing this branch the way #3-#5 were reviewed.

An import that failed after the frames were copied left them in the
scene. The copy is made first, then its textures are carried, its rows
built and its collision read; a failure in any of those answered "the
import failed" with the frames still in the resource - in the next save,
with no row in the tree to delete them by and no undo entry. The catch
also named five exception types, so a collision file that does not decode
went past it as a tool error, same leftovers. Import now takes the copy
back out when it fails before the edit is on the undo stack, catches
whatever is thrown, and says in the refusal that nothing was left. Edits
of one import that were already applied when a later one throws are
undone again; an actor already in the pack is removed; GPU meshes made
for rows that will never be shown are released. A failure AFTER the edit
is registered (writing the note that lets the next import share geometry)
no longer reports the import as failed.

Redo of a copy that shares a geometry block threw on the next save. A
second import of the same object draws from the first one's block and
lists none of its own. With the first copy deleted and the second import
undone, a save prunes the block; Reattach put back only the blocks the
copy lists, so the redone mesh named a block the resource did not have
and the save after it died in UpdateFrameData (KeyNotFoundException).
Reattach now also re-registers what its meshes point at, as
DetachedFrames does.

A save swept textures that undo could still bring back into use. Carried
textures no material of the scene names are removed at save - but the
import can be redone and a deleted object's delete undone, and neither
carries anything again: the object came back naming textures that were
gone from the working copy and its manifest. Swept textures are now
parked - out of the manifest, into illusion_parked\ under a name no scan
for *.dds answers to, with their manifest entries kept field for field -
and the next sweep that finds the scene naming one puts it back. What an
earlier run of the program parked is dropped at the first save of the
next; a texture carried again while parked wins over the parked copy. A
file that cannot be moved leaves the texture where it is for the next
save instead of failing the save.

--probe-object-transplant hill shops\harry.sds 50/50 (12 new): sweep with
the objects in / undone / saved twice / redone (same bytes, same manifest
entry, MIP companion with it) / swept again / carried again while parked
/ left by an ended run; the shared block after delete + undo + save +
redo, saved and read back. Without the Reattach change the same probe
dies with the KeyNotFoundException. Live, against the editor: a source
whose collision file is cut short - before the change every later import
under that name answers "already taken"; after it the same refusal twice
and no rows. A scenery import (250 hulls) and an actor import still work,
with undo, redo, undo. Not checked: the failure paths inside ImportPlaced
after the pack took the actor, and a failing GPU mesh.

* test(import): the carried-again check asks for the textures, not for their count

Higher in the stack a texture can also be borrowed from a third archive, so
the second carry brings more than the three objects brought the first time.

* fix(import): the lesser findings of the self-review - carry, links, names, numbers, thumbnails

The second half of the review of this branch. One commit because the
pieces lean on each other; by finding:

Carry. A refused or failed import now takes its carry back: the working
copy's manifest, prefab containers and carried register are noted before
the carry and restored, and the files the manifest gained are removed
(ArchiveCarry.Note / TakeBack). It used to leave textures, item
descriptions and a prefab entry behind for an object that never arrived -
the pack refuses an actor only after the carry. The texture a mesh names
itself (OMTextureHash) is carried, and counted as in use by the save-time
sweep. A MIP companion is copied before the texture that needs it, and a
texture whose companion cannot be brought is not brought. RemoveEntry
writes the manifest through a temp file, as AddEntry did.

Object-to-hull link. The hulls an imported object was given are found by
distance to the object's box, not its pivot - a district mesh's pivot can
be metres from its geometry, and its hulls were then never linked. A move
typed into the Transform panel takes the hulls along (it went through
RecordTransform, which knew nothing of them; past 5 m they were lost for
good and a later delete left them in the .col). Renaming the object moves
its record. The record is written by an edit of the import (and of the
duplicate), so undo takes it back out of the file. Links are worked out
once per operation for the whole selection instead of once per node, each
time re-reading the file and walking every object and placement.

Source. object_import takes `occurrence`: the n-th thing of that name in
the source (actors first, then frames - the ones that draw before helpers
of the same name), and reports how many there are. Position and heading
must be finite. The source archive is kept between imports from it (let
go after a minute, re-read when its working copy changed) instead of
being read whole for every object. "N hull(s) of its own" is now worded
as what it is - the hulls standing inside its box - in the result and in
the tool description.

Prop thumbnails. The archive is read on a pool thread (Stage) and only
drawn on the UI thread (Draw); the tick is guarded, since nothing above a
DispatcherTimer catches; the kept picture is keyed by the working copy's
time stamp as well and replaces the older ones; the held scene is let go
when the tab has all its pictures; texture folders no longer accumulate
across archives (TextureLibrary.ClearFolders).

--probe-object-transplant 53/53 (the take-back on a scratch copy; an
OM-named texture through carry, sweep, park and return).
--probe-object-import-live (new; a real window with `hill` loaded, the
district's working copy compared with its state before and put back):
30/30 - typed move and turn with undo/redo, drag, rename and its undo,
delete and its undo, everything undone leaves no record, occurrence past
the end, a source whose collision is cut short refused twice with the
same answer, a door the pack refuses leaving manifest and folder as they
were. --probe-prop-catalog 9/9, --probe-editor-tools 23/23.
Not checked: the Props tab filling in a window (only Stage/Draw are), a
companion that fails to copy, hulls linked through the box on an object
whose pivot really is far off (the probe's object has it close).
Not changed: "its own hulls" are still chosen by position - an archive
has nothing else to go by; item descriptions and the prefab entry of an
UNDONE import still stay in the working copy.

* fix(import): review of #6 - links by place, blocks shared only when alike, the Props tab paged

What the review of this branch found that the commits before this one had
not already answered (the failed-import rollback, the swept textures and
the numeric-edit path are in 56f5f8d and 45236da).

A collision link is a placement, found by its place. The record kept one
hash per hull: two placements of the same hull collapsed into one entry
(Distinct), a resize re-cooked the hull under a new hash the record did
not learn, and a placement was looked for within 5 m of the object's
pivot, which says nothing about where a placement's origin is. Each entry
is now one placement - the hull and where it stood in the object's own
space when the link was made - and it is found where the object's matrix
now puts that point, which every move, turn and resize the two make
together leaves unchanged. The same hull twice is two entries; a resize
rewrites the hashes in the record as an edit inside the same undo step;
a record written before this (bare hashes) is read as before and matched
by nearness to the object's box.

A geometry block is shared only when it draws the same way. Matching
buffers and decompression values was taken as "the same block", so a
second import inherited whatever had been done to the first copy's block
since - a draw distance set to 0, and an object that does not draw. The
draw distance, vertex layout and count and both capsules are compared
(FrameLOD.DrawsLike); the buffers are still shared, the block is copied
when it differs.

The Props tab makes cards a page at a time. A wrapping panel does not
virtualize, and the whole catalog - 2,900 objects here - was given cards
at once, about two seconds of the UI thread on opening and on every
filter change. 120 cards, and the next 120 when the end of them comes
within reach; pictures go to the cards in view first. The picture timer,
stopped when the tab is hidden, starts again when it is shown - a catalog
already loaded had nothing to restart it.

--probe-object-import-live 36/36 (6 new: a resize gives the hull a new
hash and the record follows, undo puts both back; a second placement of
the same hull nine metres from the pivot is the object's too, and both go
with it). --probe-object-transplant 54/54 (a copy whose draw distance was
changed does not lend its block). --probe-prop-catalog 12/12 (the tab in
a 330x700 window: 120 cards for 2928 objects, 240 after scrolling to the
end, pictures resume after hide and show).
Not checked: a record in the old format on a real working copy.

* fix(props): the picture cache of an earlier build is left alone

45236da gave a prop's kept picture a name that carries the state of its
archive, and on first use deleted every picture under the old name as
something nothing reads. A build from before that change, in use beside
this one on the same folder, reads exactly those: its Props tab lost all
2,928 pictures the first time a probe of this branch ran. Only a prop's
own pictures from earlier states of its archive are dropped now.

--probe-prop-catalog 12/12; 2,928 old-format pictures still there after it.

* fix(bridge): what a second look at 838c69a found

838c69a made the texture files of a push part of its undo and put a
refused material's files back. Read again before it went up:

Undo removed a texture the push had introduced. The material that names
it is created by an edit of its own and outlives the push's entry, and
Blender - told the push arrived - sends pixels again only when the image
changes: after one Ctrl+Z the texture was in no archive, and the next
push put the material on the object with nothing to draw it from. The
undo entry takes back pictures that were REPLACED; an introduced one
stays with its material.

Undo and redo did not put the archives back on the build list. Push,
Build, Ctrl+Z: the folder had the old picture, the game's archive the new
one, and Build answered that there was nothing to build.

A push that only repainted a texture was dropped from the history by the
first unload of any district: its entry names no objects, and 'all of its
objects have left the scene' is true of none.

A write that failed was remembered as done. The name was noted before the
write; the next object of the push sharing the image was handed it as
success and its material named a file that was never made. Noted after.

A texture half written - the entry replaced and its top level, or the
manifest, not - was caught with nothing on record to put it back from. It
is put back before the failure is passed on, and neither that nor
AtomicFile leaves its .tmp beside the target any more.

An object is refused before any of its materials is touched: every slot
is checked first for what can be refused without writing (normal map
space, images that did not arrive whole). A bad second slot used to be
found after the first slot's pictures had been replaced. A texture that
cannot be copied into the object's archive refuses the object as well.

--probe-bridge-material 61/61 (3 new: the two-slot object; a texture held
open refuses both objects sharing the image and leaves the picture, the
manifest and the folder as they were). --probe-material-editor 65/65.
The undo entry itself (introduced texture kept, archives re-enlisted, the
entry surviving an unload) is not covered by a probe - it lives behind
the editor's history.

* fix(mcp): a layer is not switched off over unsaved edits; moves and imports take finite numbers

A second look at f12155b. editor_open_area was given a guard against
dropping unsaved edits, and view_set was left without one: switching the
crash (or collision) layer off unloads it, and an unsaved move of a crash
copy went with it, undo entry included - the same loss by a shorter road,
and an easier one to take now that scene_find hands out crash copies.
Refused while the scene has unsaved edits, unless discardUnsavedEdits is
passed; switching a layer on, or off with nothing unsaved, is as before.

object_move and actor_import took a position or an offset as it came: a
number too large for a float arrives as Infinity and was written into the
transform. They refuse what is not finite, as property values already do.

--probe-editor-tools-live (new; a real window with a district loaded,
nothing saved) 12/12: both refusals of non-finite numbers; the layer off
and on with nothing edited; a crash copy moved, the layer refused, zones
still switch on, the layer off once the edits are given up.
--probe-editor-tools 23/23, --probe-mcp 66/66.

* fix(mirror): a second look at 3696630 and a3b5f73

Read again before they went up.

A material block worn by two objects was settled twice. The mirror writes
winter's hashes into the block, so the second wearer was resolved from
them as if they were summer's - counted as a slot the modder had
re-pointed, or substituted again where a winter hash is also a summer
one. The wearers of each block are gathered first and the block is
settled once, from summer's hashes, by whichever wearer the pair has an
answer for.

A mesh that existed only in winter was lost without a word. 'Dropped' had
become the shipped pair's meshes summer no longer has; it is what the
mirror takes out of winter - every winter mesh the mirrored scene has no
counterpart for.

A refreshed texture kept its old manifest entry. A repaint at another
size changes whether the texture has a MIP companion, which its entry
states (HasMIP); the bytes were brought up to date under an entry that
promised a companion no longer there, or hid one that now was. The entry
is compared with summer's and replaced with it.

The material list's tiles draw a blended material blended, as the preview
beside them does since a3b5f73. And the add-on's push signature includes
the colour space of an alpha mask: an 8-bit mask is linearised or not by
its tag, so retagging it changes what is sent while name, file and size
stay the same - and nothing was resent.

--probe-season-mirror uppertown 28/28 (5 new: a mesh only in winter is
reported as dropped; a block two objects wear, the wearer that never wore
the summer material first in the file; a refreshed texture's entry
follows a changed HasMIP). --probe-bridge-material 68/68,
--probe-material-editor 65/65. The add-on change is compiled, not run in
Blender.

* fix(import): a third pass over the collision links, the sweep and the Props tab

Read again before it went up.

A hull changed on its own lost its tie. Resized by itself it is re-cooked
under a new hash, and only a resize of the OBJECT rewrote the record; the
next move or delete of the object left the hull behind - or took some
other placement of the old hull that stood near. Moved by itself past
five metres it stopped being found. The record now follows a placement
through whatever is done to it: hash and place are brought up to date in
the same undo step, whether the placement was changed with its object or
alone.

Two ways of moving an object did not carry its collision: a transform
pushed from Blender, and a move of a frame the object had been parented
to. Both go through the same carrying as a typed move now (CarryLinked),
and what was given to anything UNDER a node counts as riding with it -
for a drag, a typed move, a push and a delete alike. One description of
which placement is whose (Assignments) serves all of them.

Renaming an imported object onto a name that already had a record wrote
over that record, and the undo then took the remainder away from the
other object as well. A record is never moved over another.

Parking a swept texture could lose it: with the manifest entry dropped
and the file's removal failing, nothing was on record, and the next sweep
deleted the file for good. Both files are copied aside and written down
first; whatever fails after that, they are on record. The sweep itself -
housekeeping in the middle of a save - no longer lets an unreadable
manifest end the save between the scene and its buffer pools. And a redo
of an import puts its parked textures back at once, not at the next save.

The Props tab could stop paging at the very end of the list: a page held
back by the one-per-gesture rule was never asked for again, since no
scroll event comes when the view is already at the bottom. It is looked
at again a moment later. Reading a prop's archive no longer holds the
lock the UI thread takes when the tab is left.

--probe-object-import-live 44/44 (8 new: a hull resized alone and its
undo; a hull moved fourteen metres alone still goes with its object; a
transform pushed through the push batch carries the hull and undoes; an
object renamed onto another's name leaves that record alone, before and
after undo). --probe-object-transplant 55/55 (a redo returns parked
textures). --probe-prop-catalog 12/12, --probe-season-mirror 28/28.
Not checked: a move of a parent frame with an imported object under it;
the paging retry and the tab being left during a read, in a window.

* fix(bridge): the pre-pass refuses a material it cannot make; no empty undo entry

Two things 4034b0f left. The check of every slot before any is written did
not include a material that would have to be made and arrives with no
picture to make it from - new, or one Blender remembers and the library
no longer has: found at its own slot, after an earlier slot's texture had
been replaced. Both are refused in the pre-pass now, in the words they
had. And a push whose only texture changes were introductions made an
undo entry with nothing in it - a Ctrl+Z that did nothing and cost the
redo branch.

--probe-bridge-material 62/62 (1 new: a repaint first, a new material
with no image second - refused, the first left as it was).

* fix(mcp): the layer guard is about the crash layer only

01973ba guarded switching the collision layer off as well, on the belief
that it unloads the layer. It only hides it: the placements, their edits
and their undo entries stay, so the guard refused for nothing and its
discardUnsavedEdits discarded nothing. Crash only, which does unload. And
object_move refuses a position plus offset that overflows, though each
number alone is finite.

--probe-editor-tools-live 14/14 (collision on and off with an edit
unsaved; the overflowing move).

* fix(mirror): wearers that disagree, names that differ in case, an entry that cannot be copied

Three things 7750e77 left. Wearers of one material block that give
different winter materials for a slot: the first in file order won and
both were counted as settled; the slot keeps summer's and the objects are
put down as in doubt. A texture entry compared with summer's by exact
spelling of the file name: a material that spells it otherwise than the
manifest made every mirror rewrite the entry and report a refresh. And
the old entry was removed before knowing summer's could be copied in its
place.

--probe-season-mirror 28/28.

* fix(import): the collision links once more - typed and pushed moves of a hull, two objects of one name, parking on record first

What a second independent reading of d880b76 found.

A hull moved on its own by a typed number or by a push from Blender still
lost its tie past five metres: it was looked for after it had moved, and
only the drag asked beforehand. It is recognised by where it stood
(LinksOf takes the place it had), and the record follows.

A push from Blender carried a hull that Blender itself places. With the
hull in the session, the editor moved it with its object and the next
push moved it back - or, with both moved in one push, it went twice. A
push carries only placements the session does not hold; what rides with
each moved object is worked out once for the batch, before anything is
applied, and the records are brought in line once at the end, with
everything where the push leaves it.

Two objects of one name. A record's owner was the first frame of that
name in the file, so renaming an earlier object onto a later one's name
handed it the later one's collision. Among namesakes the owner is the
one the record fits - whose matrix puts the recorded places where those
hulls stand. And a rename moves a record only for the one object bearing
the name: renaming the newcomer away again leaves the other's record be.

A record from before places were kept gains its places the first time
anything is done to one of its placements, instead of never.

Parking wrote its index at the end of the sweep, after the originals were
gone, and the sweep now swallows failures: an index that could not be
written left textures removed and nowhere on record. The index is written
before anything is taken out of the working copy; a texture whose removal
only half succeeds stays carried and is tried again.

The prop reader no longer re-holds an archive the tab let go while it was
being read.

--probe-object-import-live 48/48 (4 new: a hull typed sixteen metres
away on its own stays the object's, with its undo; an earlier object
renamed onto a later one's name does not take its collision, and renamed
back each has its own). --probe-object-transplant 55/55,
--probe-prop-catalog 12/12.
Not checked: a push with a hull in the Blender session (no Blender in the
run - the batch is driven directly, with no session); an imported object
under a moved parent; the parking failure paths.

* fix(mirror): the same bytes are not written again for an entry that cannot be copied

b2f3b79 left one case: a texture whose bytes already match summer's but
whose manifest entry differs from a summer entry that cannot be copied
fell through to the write, so every mirror rewrote the same file and
reported it as refreshed. Nothing is written and nothing is reported.

--probe-season-mirror 28/28. The case itself has no probe: no stock
archive has such an entry.

* fix(import): a rename moves the collision record by whose it is, not by there being a namesake

c5ec22a made the record follow a rename only when no other object bore
the old name. That is the wrong question. An imported object renamed onto
a stock object's name brought its record there, and the undo then left it
behind: the old name had a second bearer. The same for an object renamed
away from a name it shares - its collision stayed with the namesake.

Whose the record is is now asked before the name changes, with the answer
every move, delete and duplicate already goes by: the one bearer, or among
several the one the record fits. The asking moved from the property
catalog (which cannot see the collision layer) to PropertyEditController;
the catalog's setter is the plain one again. An entry from before places
were kept counts in the fit by how near its hull stands to the object;
skipped, such a record fitted every bearer equally and went to the first
in the file.

Also: a click on the gizmo that moves nothing no longer brings an old
record up to date and leaves an undo step with nothing to show for it.

--probe-object-import-live 54/54 (6 new: onto a stock object's name and
back, the namesake renamed away, the object renamed away, both undone),
--probe-object-transplant 55/55, --probe-prop-catalog 12/12,
--probe-editor-tools 23/23. Not exercised: two bearers of a name whose
record has only old entries.
mafia2online#6 was stacked on this branch and was merged as one commit, so main already has every
change made here. The merge takes main's tree as it is: nothing of this branch is left
to add.
Segfaultd pushed a commit that referenced this pull request Oct 7, 2026
…the shipped memory requirements (#7)

* feat: preview a material on the context mesh slot in the Material Editor

Local work found uncommitted in the working copy (base a55dc22, v0.3.1):
sphere/mesh preview toggle, the slot label on the assign panel, and the
SDK pin moved to 10.0.302.

* feat: turn a material made in Blender into a game material on push

A push used to refuse any slot whose material had not come from the
toolkit. A Blender-made material now travels with the image wired into
its Principled Base Color and becomes a real game material:

- addon: sends the image as RGBA8 (top-down, resampled to powers of two)
  once per push however many slots use it; the ack stamps the hash back
  on the datablock so later pushes send pixels only when they changed
- DdsEncoder: DXT1 with a full MIP chain and the stock header, written
  as a single Texture entry (HasMIP 0) - the shape 2809 stock city
  textures ship in, so no companion Mipmap entry is needed
- AuthoredMaterialResolver: fills in the hash before the mesh path runs;
  binds by name when the game already has the material, otherwise writes
  the texture into the object's archive (file + manifest) and creates a
  default-preset material with it in S000 (one undo entry)
- a re-push with new pixels rewrites the texture in place and the
  renderer reloads it (TextureLibrary.Invalidate)

Probe: --probe-bridge-material [district] [push.ilx] - encoder, resolver,
re-push, bind-by-name, refusal, and optionally a container written by
the addon itself.

* fix: stop the Material Editor throwing while its XAML loads

The Sphere toggle is checked in markup, so its Checked handler ran before
the Mesh toggle and the preview existed and the window died with a
NullReferenceException on open. The handler now waits for both.

The probe read the assign BUTTON's visibility; the button is hidden
through its panel since the mesh-preview change, so it reads the panel.
--probe-material-editor: 65 passed, 0 failed (was 50 + an exception).

* fix: live-test the Blender material push and close what it found

A live probe drives the real viewport and a real bridge session
(--probe-bridge-material-live): an off-screen D3DImageHost loads an
archive, opens a mesh in a running bridge Blender and waits for an
object made there, wearing a material made there, to be pushed back.
It checks the scene, the renderer, a re-push with a changed image,
undo/redo, Save, a reload from disk and a pack, then restores every
file. 21 passed against Blender 5.1.2.

What it found:
- image resize went through Image.copy().scale(), which hands back the
  blank original of a painted or generated image - the texture arrived
  black. The addon now resamples the pixel array itself.
- a rebuilt mesh's parts carried no material hash, so a later material
  edit (or a texture rewritten by a push) could not find them to
  re-resolve. The hash and tint now ride with the part, as on a mesh
  loaded from disk.
- Blender keeps the hash of a material the library may have lost
  (creation undone, toolkit closed without Save). With pixels it is
  made again; without them the push is refused once and the ack tells
  the datablock to forget, so the next push arrives complete.

* fix: store large textures the way the game does, and stop Build losing resources

A Blender-made material came out BLACK in game. The object was there and
the material was sound (same shader, flags and sampler states as 1888
stock materials) - the texture was not in a shape the game loads.

- From 256x256 up the game stores a texture split: the top level alone
  in a Mipmap entry, everything from half resolution down in the Texture
  entry with HasMIP=1 (6747 split textures surveyed, no exception; no
  stock mip-chained texture is wider than 128 on its short side). The
  encoder wrote a 1024x512 texture whole. DdsEncoder.Encode now returns
  the pair, and ArchiveTextureWriter writes both files and both entries.

Two packer bugs found while comparing the rebuilt archive with stock:

- TextureHandler charged a split texture only its own payload. Stock
  charges the Texture entry its payload PLUS its MIP companion's and
  leaves the Mipmap entry at zero (381 of 381 in italy.sds), so every
  Build under-reported the archive's video memory by the size of all its
  top levels - 6 MB for italy.
- PruneMissingEntries joined a rooted resource name ("/missions/...")
  with Path.Combine, looked for the file at the drive root, and unsaid a
  resource that was present. italy.sds lost its AudioSectors on Build.

--probe-bridge-material: 30 passed (split layout, the memory figure
against the files on disk, pruning with a rooted name).

* fix: give a created material the two fields the stock ones carry

The billboard was still black in game after its texture was stored
correctly. The material had been compared with stock on shader, flags,
sampler states and parameters - through a view that does not show every
field. Two were left at zero by the Default preset:

- Unk0: 128 on 1837 of the 1929 stock materials on that shader
- the diffuse sampler's TexType: 2 on 1947 of 1951 (0 on four)

Material_v57's Default preset now sets both, which also covers the
materials the glTF import creates. v58 (Definitive Edition) is left
alone: there is no DE install here to measure it against.

The probe compares a created material with the majority of stock
materials on its shader in these fields, so a preset that drifts from
the game's own records fails here instead of in game.

* fix: rename a probe local that clashed with one further down

The previous commit did not build: the new stock-record check declared
'made', which the addon-container section of the same method already
uses. --probe-bridge-material: 25 passed.

* feat: carry a Blender material's normal and specular maps into the game

A Blender-made material arrived with its Base Color image only. It now
also brings the image behind its Normal Map node and the one driving
its specular level, and becomes a normal-mapped game material.

Chosen from a survey of the stock library, not assumed:
- shader 5159568776351604322 (S000 + S001, parameter D013): 1209 stock
  materials, drawn on the P|N|T|UV0 declaration a pushed mesh already
  has. Its S001 is one combined texture - normal X and Y in red and
  green, the SPECULAR level in blue (stock "...NS" textures: red/green
  127 +- 10, blue 30..240), DXT1 like 97 of the 120 sampled.
- NormalSpecularPacker builds that texture from the two Blender images
  (either may be missing) and inverts green: Blender's normal maps have
  green up, the game's bitangent follows texture V, which runs down.
- MaterialPreset.DiffuseNormal creates the record in the commonest
  stock shape of that shader - Unk0, flags, both samplers' TexType and
  states, D013 - and the probe compares it field by field with the
  majority of the 1209.
- Blender's roughness and specular level become D013's power and level;
  the defaults (0.5 / 0.5) land on the commonest stock pair, 16 / 0.3.

A material that gains or loses its normal map needs another shader, so
it is replaced under the same name - the hash meshes refer to does not
move. The resolver now talks to an IAuthoredMaterialHost (create,
update, replace) instead of two callbacks; the application's host
records each on the undo history.

Also: binding a texture to a sampler sets its TexType to 2 (a slot
added in the Material Editor started at 0, which samples nothing in
game); two materials sharing an image share one texture file.

--probe-bridge-material: 33 passed. Driven live through the real
viewport against a windowless Blender: the billboard in italy took a
new material with all three maps and shows the relief in Render mode.

* style: dotnet format the preview viewport's mesh builder

* feat(mcp): tools that drive the running map editor

The MCP server could read game files but not touch the editor, so a client
working on a scene had to fall back on UI automation for everything the
window does. These tools close that gap:

- editor_status / editor_list_areas / editor_open_area (opens the editor
  from the launcher and waits for the area to finish loading)
- scene_find (by name, kind, or a world box tested against a mesh's
  triangles rather than its bounds) and scene_select
- blender_open / blender_end, and editor_notices for what a push reported
- editor_save / editor_build, object_move, scene_delete_selected,
  editor_undo / editor_redo
- camera_get / camera_look_at / camera_set / camera_frame_selection,
  view_set (shading mode and layers) and viewport_screenshot

Illusion.Mcp gets the IEditorSession seam; the application implements it
over the open MainWindow. Supporting changes: ViewportControl.CaptureFrame,
LookAt and LookFrom, DistrictStreamer.IsBusy, LauncherWindow.OpenMapEditor,
and a notice log the banner feeds.

* feat(mcp): blender_push, and scene_select with no names clears the selection

blender_push sends the bridge's request_push, waits for the push to land
and returns what the editor reported, so a client that edited the session's
objects in Blender gets the outcome in the same call instead of polling the
notice log. An empty scene_select clears the selection: its outline is
drawn through walls and would otherwise sit in every screenshot.

* fix(bridge): refuse a pushed mesh over 65535 vertices instead of writing 32-bit indices

A rebuild that came to more than 65535 split vertices switched the level's
index buffer to 32-bit. The toolkit's viewport draws that; the game does
not — an interior of 65 981 vertices came up as triangles smeared across
the district, textures flickering, while the editor showed it whole.

The push is now refused with the vertex count, so the modeller can split
the object. A rebuild always writes 16-bit indices, and a level that still
carries a wide buffer from before is rebuilt on its next push whatever
that push changed, which is what puts it right.

scene_find reports each mesh's vertex and triangle count, so the size of
an object can be checked before it is built into an archive.

* feat: bring a light into a district that has none — actor import across archives

A city district ships with no lights of its own: its interiors are lit by
LightEntity actors, and the only way the editor could make an actor was to
copy one already in the pack. ActorsFile.Import copies an actor out of
another archive's pack, with its own copy of the behaviour row it points
at; the editor wraps it as one undoable edit and gives it a row in the
tree, creating the entity type's section when the district never had one.
Only an actor that places no object of its own scene can travel — a light,
a sound — and the copy is linked to a frame named after itself, which is
the frame such an entity makes.

A light keeps its transform twice: in the actor record and at the head of
its behaviour blob (every shipped light's x appears exactly twice). The
record is what the editor moves, so the pack now carries the translation
over into the blob when it is written; an untouched pack still writes byte
for byte.

MCP: actor_import, scene_duplicate_selected, object_properties and
object_set_property (the property panel as data, undoable writes), and
decode_actors can list each actor's behaviour fields.

* Keep a light's inverse matrix and clip box in step with where it stands

A light's behaviour blob ends with the inverse of its world matrix, and the
game works the light out through it. A light imported from another archive
(or moved in the editor) kept the old inverse: every field read correctly,
the light stood in the right place, and it lit nothing.

SyncLightFrame now rewrites the inverse whenever it is out of step with the
head matrix, carries the clip box along by the distance the light moved, and
runs on import as well as on write. Untouched packs still write byte for
byte: shipped lights are within 1e-4 of a true inverse and are left alone.

* Show and edit a mesh's draw distance in the property panel

Each level of detail carries the distance at which it stops being drawn,
stored squared. It is the one cost control a static object has - the game
culls by distance and view cone only - and an object made through the
Blender bridge starts at a million metres. The panel (and so the MCP
object_set_property) now lists one draw distance per level, in metres.

* fix: touch a light's row only when the light has moved

The light sync rewrote any inverse matrix that was out of step with the
head matrix. The install has 72 such rows that shipped that way (three
Joe's Adventures archives carry zeroes or noise there), so re-saving those
untouched packs changed them: --probe-act-relayout, --probe-actor-props and
--probe-native-misc fell to 1047/1050.

Now a light whose record and head translation agree is left alone, and a
moved light gets a new inverse only if the one it had was a true inverse.
The clip box and inverse are also written to the row's named fields: on
write the core pokes every named field back over the blob, which put the
imported light's old box straight back.

--probe-act-relayout gains the import: a light from one archive into a
pack of another, checking its own row, matrix, inverse, clip box, the
writer round trip and a refused duplicate name. 1050/1050 packs re-save
byte for byte again.

* docs: the editor tools, the Blender material push and their limits

README: the MCP section lists the 25 editor tools and says how they relate
to the editor's own undo, save and build; the bridge section covers pushed
materials and the 65535-vertex refusal; the property panel mentions draw
distances; Known limits gains opaque-only pushed materials and undrawn light
actors. --probe-mcp now expects the editor tools as well.

* feat: carry a Blender material's alpha into the game - cut-out and translucent

A material made in Blender was always opaque: its diffuse went to DXT1 and
its flags were the plain ones. Now whatever feeds the Principled BSDF's
Alpha - the image's own alpha, a mask image, or a value below 1 - is written
into the diffuse texture, which is then stored as DXT5, and the material's
render method picks the mode: Blended is a translucent surface, anything
else a cut-out.

The game has no separate shader for either on the shaders the bridge
creates materials for; the flag word decides. Measured over default.mtl on
the diffuse shader: 239 materials set Alpha and keep writing depth (fences,
signs, foliage), 323 set Disable_ZWriting on DXT5/DXT3 textures (overlays),
11 carry both. A cut-out gets Alpha and keeps CastShadows; a blend gets
Disable_ZWriting and drops CastShadows; every other bit is left alone.

- DdsEncoder: DXT5 (interpolated alpha block + the DXT1 colour block), with
  the header stock DXT5 files carry.
- MafiaMaterialCatalog: GetFlags / SetFlags; the editor's SetFlags is an
  undoable edit.
- The addon sends alphaMode with the material and puts the alpha it means
  into the image; an opaque material keeps its old signature, so nothing is
  resent for nothing.
- --probe-bridge-material: 40/0, with the encoder's alpha round trip, the
  flag words, and one material taken through cut-out, blend and back.

* feat(viewport): draw alpha-blended materials blended

The mesh shader alpha-tested every textured material at one half. That is
right for a cut-out and wrong for glass: a pane whose alpha is 0.3 was
clipped away entirely, so a translucent material - a stock one or one
pushed from Blender - was simply not there in the viewport.

A part now knows whether its material blends (Disable_ZWriting in the flag
word), and such parts are drawn in a pass of their own after the opaque
one: depth-tested, never depth-written, standard alpha blend, no clip. The
flag follows a material edit the same way the textures and the tint do.
Unsorted on purpose - a pane seen through a pane may composite in the wrong
order. Instanced props keep the alpha test.

* feat: mirror a district's edits into its winter archive

A district ships twice and an edit made to <name>.sds is simply absent
from <name>_z.sds. For most districts the two are one scene: measured on
uppertown, every shared resource (buffer pools, name table, actors,
collisions, item descriptions, prefab) is byte for byte the same, and the
frame resource has the same length and differs in 1740 places - each a
material hash swapped for its ^zima counterpart, 82 pairs. Textures aside,
nothing else differs.

SeasonMirror writes the summer working copy over the winter one with each
object's winter materials taken from the winter object of the same name
(and occurrence - names repeat), copies the shared files that differ, adds
the pools summer gained with their manifest entries, and adds the textures
the result names that winter lacks. The caller queues the winter archive
for Build.

Not every district is such a pair: 13 of 23 are, ten have a winter scene of
their own. The pair is therefore judged first on the archives as shipped
(each one's oldest backup, or the archive itself if never built), and a
district that fails is refused rather than overwritten.

- MCP: editor_mirror_winter.
- --probe-season-mirror: 11/0 on scratch folders built from one working
  copy, so it does not depend on what the modder did to the install - an
  untouched pair is left alone, a missing object arrives and winter is
  byte for byte whole again, a stranger is refused untouched; reports which
  of the install's pairs are twins.

* feat: carry objects from one archive into another

A district can now be furnished from the rest of the game: a door from a
shop, a chair or a plant from an interior, brought into the loaded area as
one undoable edit (MCP object_import).

- FrameTransplant copies a frame subtree across resources: frames re-homed
  (FrameEntry.MoveTo), geometry and material blocks deep-copied once per
  shared block, every buffer they draw from copied into the district's
  own pools under fresh names. Links inside the subtree follow the copies;
  the root becomes a prototype (an actor places it) or scenery anchored to
  the district's main scene, on the name table, standing where it is put.
- ActorsFile.Import takes an actor that places an object once that object
  has been carried, links it by the copy's name through a scene reference
  of its own, and can turn it.
- ArchiveCarry brings what the scene does not hold: the textures the
  materials name, the item descriptions the collision frames name, and the
  prefab entry the definition names (PrefabFile.Adopt, which keeps the
  container's size header right - the core writes it as it is handed it).
  Keys travel unchanged; what the district already has is shared.
- ObjectImportController shows the copy (tree rows, GPU meshes, actor row)
  and undoes it as one edit.
- A frame named by hash alone (weapon parts) kept a zero hash when copied:
  the copy constructor rebuilt the name from its empty string. Fixed for
  the duplicator and the prototype cloner too.

--probe-object-transplant: 30/0 - a door, a prop an actor places and a
piece of scenery go from shops/harry.sds into a scratch copy of a district,
keep their shape frame for frame and their buffers byte for byte, survive a
save and a reload (actors resolve, meshes decode from the district's pools,
prefab and item descriptions in the manifest), and an undo leaves the scene
byte for byte as it was.

* feat: a Props tab to drag stock objects into a district

The map editor's property rail gets a Props tab: the stock game's doors,
seating, tables, beds, storage, plants, lamps and decor, one card per
object with a picture. Drag a card onto the viewport to put the object
where it lands (on the scene's surface under the cursor), or double-click
it to put it in front of the camera.

- PropCatalog finds them in the extracted interiors and districts: every
  actor that places an object of its archive (door, physics prop, framed
  furniture), and scenery whose name says what it is and whose size is a
  piece of furniture. One card per object across archives; interiors are
  read first, so a card points at the stock source. The scan (~6 s) is
  remembered per archive and redone only for what changed.
- PropThumbnailRenderer draws each card's picture offscreen with the
  object's own textures and keeps it on disk.
- Scenery now brings collision: the source's hulls that stand inside its
  footprint, re-placed by the same move (CollisionCarry), or one cooked
  from its triangles when it had none - small props usually have none.
- A door carried out of its building gets ActorActionsEnabled: a shop's
  front door ships with the player's actions off, its shop script turns
  them on, and nothing does that where it lands.
- The import core moved from the MCP session into ObjectImportController,
  shared by object_import and the tab. A card from the loaded district is
  duplicated in place rather than refused.
- RefManager hands out ids atomically: the catalog parses scenes in the
  background while the editor loads districts.

--probe-prop-catalog: 7/0 - 2747 props in 6.3 s, 52 ms remembered, known
doors and chairs on their shelves, each shelf's first card gets a picture.

* feat: simplified hulls for imported scenery, and a props column

- A hull cooked for scenery that had none of its own is now its convex
  hull by default (ConvexHull: incremental, points snapped to a grid a
  fortieth of the object's size) - a coat rack goes from 1474 collision
  triangles to 22. Also a box (12 triangles) or every render triangle,
  chosen per import: object_import's 'collision' (auto / convex / box /
  mesh / none) and a chooser in the props panel. A flat thing has no
  convex hull and gets a box.
- The props panel moves out of the property rail into a column of its own
  left of the viewport: as tall as the window, sized with a splitter,
  shown and hidden by a Props button on the toolbar; the viewport's tool
  shelf moves with the viewport and is no longer covered.
- Textures an import carries are written down beside the working copy
  (illusion_carried.json, outside the manifest) and each save sweeps the
  ones no material of the scene names any more - an undone import, or one
  lost with a scene closed unsaved, no longer leaves megabytes behind.

--probe-object-transplant: 33/0 (+ the hull of a cube is the cube, a stock
model's hull holds all its vertices and faces out, a flat thing gets a box).

* feat: an imported object's collision moves and is deleted with it

Nothing in an archive ties a static object to its collision - the hull is a placement that happens to stand where the object stands. For scenery the toolkit carried in, the tie is now written down beside the working copy (ImportLinks, illusion_links.json, outside the manifest): the object's name and the hashes of the hulls it was given. A gizmo drag of the object drags those placements along (a scale mints a scaled hull as it does for any placement), and Delete takes them with it; a Delete that went through several editors is one Ctrl+Z (EditHistory.SquashSince). Stock objects are untouched: nothing ties them, and guessing by position would take a building's hull along with a bench.

* feat: a duplicate of an imported object gets copies of its collision

Duplicating scenery the toolkit carried in also places copies of the hulls it was given, where the copy stands, links them to the copy under its own name, and the whole duplicate is one Ctrl+Z. Linked hulls are now claimed one to one across the scene, nearest first and ties to the name that sorts first: a copy stands on top of its original with the very same hull, and dragging it used to drag the original's hull away.

* feat: save drops geometry nothing draws; a re-import shares geometry

- Save writes the buffer pools without the buffers no geometry block
  draws from - left behind by a deleted object, a sanitised block, a
  rebuilt mesh. Measured: none of the 130 archives as shipped carries one
  (--probe-geometry-sweep), the install's working copies had ~0.5 MB.
  Only the files lose them: memory keeps them, so an undo after a save
  brings the object back and the next save writes them again
  (SdsGeometrySaver.SavePools tracks what each pool file holds).
- Importing an object again draws from the buffers - and, when every level
  is there, the geometry block - its earlier import brought, the way the
  shipped scenes put many copies of one chair on one mesh. Where geometry
  went is remembered per source archive beside the working copy
  (ImportGeometry, illusion_geometry.json); a remembered buffer the scene
  no longer has is simply copied again.
- The bridge probes snapshotted only the pool they edited; a save now may
  rewrite others, so they snapshot and restore every pool.

--probe-object-transplant: 38/0 (+ imported twice shares, the memory
survives on disk, undoing copies keeps the first, a save leaves dead
buffers out of the files and writes them back when drawn again).

* feat(mcp): drive the resource editor; read and set car tuning

- editor_target switches the scene tools between the map editor and the
  resource editor's stage: find, select, properties, move, duplicate,
  delete, camera, screenshot, shading, save, build, undo/redo and the
  Blender tools follow it, and answer with that editor's status.
- resource_list searches the archive library (pc\sds by name or folder),
  resource_open stages one - from the launcher the way its tile does,
  beside the map editor as a second window - and waits for it to load;
  resource_status reports what is on the stage.
- car_tuning lists a car's entity-data tables (stock and tuned variants,
  labelled by mass and power) and their fields by band and element;
  car_tuning_set writes one, parsed by its kind, with the Tuning tab's
  bookkeeping: an undo entry, the archive on the build list, the tab
  re-read. A wheel's or gear's field can be named without its prefix
  together with its element.

Tried on shubert_38: 6 tables x 771 fields; Power, CenterOfMass and
Wheel0.Scale set, read back and undone. --probe-mcp: 66/0.

* feat(cars): clone a car under a new name; a Build no longer drops XML

- car_clone (MCP) / CarCloner: copies pc\sds\cars\<source>.sds and its
  winter twin to <name>.sds and renames what the name keys inside - the
  root frame and its actor link (and so the name table), the PREFAB
  entry (FNV64 of the name) and the entity-data storage (FNV64 of the
  name in lower case). Then registers the car: a vehicles.tbl row under
  a fresh id with the source's class, price and flags, its
  PaintCombinations row, its AiProps cover points, and - optionally - a
  slot in every traffic row (CARM*.tbl) that picks the source. Builds
  the new archives plus tables.sds and ingame.sds, backups kept.
- GameTable: an extracted .tbl as rows of cells, written back through
  the table codec. PrefabFile.Rekey, EntityDataStorageFile.Hash.
- Fix: PruneMissingEntries looked for an XML resource under its manifest
  name, but it sits on disk as name + ".xml" - so the first Build of an
  archive with XML resources (ingame.sds, tables.sds) unsaid every one
  of them, and the Script containers with them. XML is now found by its
  real file, containers are left alone.

--probe-car-clone: 33/0 (on scratch copies: refusals write nothing, the
rows and hashes the clone gains, the archives pack, nothing is pruned,
every XML of both table archives decompiles to the same text after a
pack). --probe-mcp 66/0, --probe-object-transplant 38/0.

* feat(mcp): archive_build packs one archive's working copy

For an edit made in the working copy itself - a script, a table file -
that no editor session tracks: packs the extracted folder back into its
.sds through SdsWriter.PackSds, keeping the usual timestamped backup.
editor_build stays the way to pack what the editors changed.

Used on pc\dlcs\cnt_jimmys_vendetta\sds_ru\missionscript\
fr_game_director.sds after a script edit: 8 resources before and after,
only the script pack differs (+301 bytes, the edit).
--probe-mcp: 66/0.

* fix: a rebuilt name table keeps the order it was loaded in

A cloned car sat in the garage with its name and no model. Two causes,
both found against the game:

- The name table was rebuilt in frame-index order. The order is
  authored, not derivable: every one of the 103 cars the game ships
  lists its root frame first, and only 34 happen to be in index order.
  The clone's table led with an effects frame. Each frame now carries
  the position the loaded table gave it (FrameNameTableOrder, set where
  the flags are linked), and BuildDataFromResource emits in that order,
  frames the table never listed after them by index. This is every
  rebuild, the Resource Editor's car save included.
- The game finds archives through its cached file list
  (%LOCALAPPDATA%\2K Games\Mafia II\Data\vfs.bin) and does not pick up
  a NEW archive on its own. GameFileIndex.Reset removes the cache, the
  way Steam's install script does; car_clone calls it and says so.

--probe-car-clone: 36/0 - the clone lists its root first; rebuilding
the name table of each of 103 shipped cars reproduces it byte for byte
(five tables that list a frame twice or name a frame the resource does
not hold are left out). --probe-nametable: 1331/1331 semantic,
960 byte-identical. --probe-object-transplant 38/0, --probe-mcp 66/0.
Whether the clone now shows in game is not yet confirmed.

* feat(cars): a clone gets buffers and a title of its own

- Buffers. The game keeps vertex and index buffers by name across every
  archive it has loaded, so a clone that kept the source's names would
  draw whichever shape streamed first once its model is edited - and so
  would the source car. Cloning now renames every buffer the geometry
  draws from, in the geometry blocks and in the pool files
  ("Shubert_38.Root.L0.VB0" -> "<name>.Root.L0.VB0"; a name that does
  not start with the model's gets the new one in front).
- Title. car_clone takes an optional title: one new string in the text
  table of every installed language (sds_<lang>\text\text_default.sds,
  tables\TextDatabase.dat - UTF-8 "KEY:TEXT" lines, id 60000017 is key
  00_60_00_0017), under an id from 60001000 + vehicle id that nothing
  uses, and the clone's vehicles.tbl row points at it. Without a title
  the clone shares the source car's name, as before. GameText reads and
  adds lines; the text archive is built with the others.

--probe-car-clone: 48/0 (the clone's buffers share no name with the
source's, every level of detail resolves, the bytes are the source's in
the source's order; the title is one added line and nothing else, the
text archive packs, its XML survives the pack, nothing is pruned).
In game: the first clone showed in the garage with its model after the
name-table fix; this build of it is confirmed to start, not yet looked
at in the garage.

* Packer keeps shipped memory requirements; export a car as an M2O resource

A rebuilt archive stated payload sizes where the shipped one stated what the engine should budget (a car asked for 148 079 bytes of slot RAM against 170 232, and no 'other' RAM). The figures are now read from the archive as it shipped, kept beside the working copy (illusion_memory.json) and applied on pack; a clone carries its source car's. archive_build takes memoryFrom for a clone built before that.

car_export_m2o writes a resource folder for Mafia II Online: package.json, cars/<name>.sds (+ _z) and vehicles.json with the model, titles, source car, key hashes and the vehicle/paint table rows. Probes: --probe-car-clone (memory), --probe-car-m2o.

* feat(cars): build a car under another car's name

car_substitute replaces <target>.sds with the source car's model keyed by the target's model name (root frame, name table, prefab entry, entity data, buffers), keeps a backup and touches no table - for trying a car where models are spawned from a fixed list of names.

* feat(cars): the M2O car export follows the map export's layout; docs

Archives sit under sds/cars/ in the exported folder and each entry names the path the game loads it from (sds, winterSds), the way the map export names its patch targets. README: the car tools, memory requirements, the tool count.

* perf(bridge): read a level's index width without decoding the mesh

Review of #3. HasWideIndices decoded the whole level - every vertex
channel, the index array and, on a model, the skin - to read one field of
its index buffer, on every push, ahead of a path that decodes the mesh
again. It now clamps the level and looks at the index buffer directly.

--probe-bridge-rebuild 17/17, --probe-bridge-vertex 2/2,
--probe-bridge-resplit 6/6.

* fix(mcp): editor tools say no when the editor does, and find crash copies from the table

Review of #3, the findings in the MCP session.

editor_open_area dropped unsaved edits. Changing the area or the season
reloads the scene, which clears the edited frames, the unsaved list and
the undo history, and the tool only checked for a Blender session. A load
that would replace a scene with unsaved edits is now refused unless the
caller passes discardUnsavedEdits=true; asking for the area and season
already shown stays a no-op.

editor_save and editor_build reported success after a save that did not
complete. The viewport's save does not throw for a material library that
would not write or a car's working copy that was refused - it posts a
notice and carries on - so the tool had nothing to return but success, and
a build went on to pack archives whose materials were not on disk.
D3DImageHost.SaveEditsReport hands back what was left unsaved;
editor_save answers success=false with notSaved, and a build whose save
did not complete packs nothing and says why.

object_set_property could not rename. Base.Name is offered as an editable
HashName and the parser had no case for it. It has one now, with the
property panel's rule (an empty name is refused), and a rename to the name
the object already has is not committed as an edit.

Numeric properties took NaN and infinity. float.TryParse accepts "NaN",
"Infinity" and "1e100" (as infinity); a float and each component of a
vector now have to be finite.

scene_find did not see crash copies until their row had been expanded in
the tree, and a box query never returned one. They are now searched from
the placement table: by the copy's own label or its prop's name, and by
the prop's triangles at the copy's matrix. Only the copies returned get a
tree node.

--probe-editor-tools (new, no game install needed) drives the real tool
methods against a scripted editor and the parser directly: 23 checks.
Checked live in the editor on uppertown: 5 lamp copies found with no row
expanded, 288 triangles of one in a box round it, none 400 m above it; a
moved mesh blocks a load of another area and of the other season, the
same area is a no-op, discardUnsavedEdits=true loads. A failing material
write was not reproduced live - that path is covered by the scripted
editor only.

* fix(mirror): the winter mirror decides against the pair as shipped, not against the last mirror

Review of #5, the four findings in SeasonMirror.

Twins were told by size. Two archives passed as "one scene shipped twice"
when their shared pools were equal and their scenes the same length - and
a winter scene with one object moved 50 units is the same length. The
scenes are now parsed, winter's material hashes replaced by summer's, and
the two written back out: anything left that differs (a transform, a
parent link, a draw distance) means winter is a scene of its own and the
mirror is refused. The 13 stock twins still read as twins and the other
ten as not.

Everything else follows from reading that pair once (SeasonPair) and
deciding against it, where the mirror used to ask the winter WORKING COPY
what winter wore - which answers with whatever the previous mirror left:

- A slot the modder re-pointed in summer got its old winter material
  back. A summer material no object of that name wore when the pair
  shipped is now carried into winter as it is (Report.Reassigned).

- Namesakes were matched by position among objects of the same name, so
  deleting the first handed its materials to the second. The season's
  substitution is now looked up by name and SUMMER MATERIAL; position is
  only used when one summer material stands for two winter ones under one
  name, and only while the number of namesakes is what it shipped as.
  Otherwise the object keeps summer's material and is counted
  (Report.Ambiguous, raised as an error notice) rather than dressed in
  another object's snow.

- A texture an earlier mirror had brought was never refreshed: the bridge
  repaints an authored texture in place, and "winter has a file of that
  name" ended the matter. A file winter did not SHIP with is now compared
  and rewritten, its MIP_ companion with it (and removed when the new
  picture has none); a file winter shipped with is the season's own and
  is left alone.

Mirror() also refuses a winter folder that is not made of the pair's
meshes before writing anything, and editor_mirror_winter stops when the
save it starts with did not complete. Its result names slotsReassigned
and objectsAmbiguous in place of objectsReshaped.

--probe-season-mirror builds its own namesakes and runs every case above
on scratch copies: 23/23 on uppertown and oysterbay, 22/22 on italy,
21/21 on greenfield (not a twin, so the shipped-texture checks do not
apply).

* fix(bridge): a material push that can be undone, refused cleanly and shared across archives

Review of #4, all ten findings.

Texture files are part of the push's undo. A repaint under the same name
changes no binding, so the catalog recorded nothing and Ctrl+Z could not
bring the old picture back. The resolver now journals every file it
writes (what the name held before, what it holds now) and the push
carries a TextureFilesEdit in its one history entry: undo restores the
old files - or removes a texture the push introduced, manifest entries
included - and redo puts the new ones back.

Nothing is written before everything is checked. The diffuse texture was
on disk before the normal map had been looked at, and before the library
had accepted the material. All images are now read, validated and packed
first; what is written before a later file or the library refuses is
rolled back from the same journal.

A split texture is compared as two files. PickName compared only the
entry, which for a texture of 256 and up starts at half resolution: a
fine checkerboard and the grey it averages to are byte-identical there
(reproduced), so the second picture took the first one's name and its
write replaced the first one's top level. The MIP_ companion - and
whether there is one - is now part of the comparison.

A material shared by two archives is packed into both. The second object
of a push binds the new material by name, and an unchanged material
arrives with no pixels; either way an archive that had never held its
textures got none. The files are now copied from the archive that has
them (only for materials this bridge created - a stock material bound by
name copies nothing).

A renamed material keeps its hash across a shader change. Gaining or
losing a normal map deletes and recreates the record, and it was
recreated under the Blender datablock's current name - a new hash, with
every other mesh left pointing at the old one. It is rebuilt under the
name the library knows it by.

Reloads are coalesced. A push of a hundred objects sharing a material
asked for a hundred invalidate-and-rebind passes over every loaded mesh;
each texture is now invalidated once and each material that names one
rebinds once (ReloadTextureFiles).

A rebound part returns the textures it held. GpuMesh kept every lease
until dispose, so the library's retired copy of each repainted texture
stayed on the GPU for as long as the mesh was loaded. Leases are kept per
part and the superseded ones released once the part holds the new ones.

The mesh preview keeps the material's tint.

Add-on: a Normal Map node in Object or World space is no longer exported
as if it were tangent space - the material is refused by name with what
to change - and the resampler clamps its two neighbours independently:
upsampling [0, 1, 0] to four texels gave 0.875 at the left edge, now 0.

--probe-bridge-material 58/58 on eastside (30 new checks: each case
above, including twenty repaints of a texture a live GpuMesh draws
leaving nothing retired), --probe-material-editor 65/65,
--probe-bridge-rebuild 17/17, --probe-mcp 66/66. The undo entry itself
was checked at the file level (restore before/after), not by pressing
Ctrl+Z in the editor, and the two add-on changes were not run inside
Blender: the resampler was checked on the reviewer's numbers outside it.

* fix: the material preview blends what the scene blends; an alpha mask is sampled linear

Review of #5, the two findings outside the mirror.

The material editor's preview still alpha-tested a translucent material.
SphereMesh forwards a part's Blended flag, but the part the preview
builds for a material carried only its tint, and the mesh-shape preview
dropped the flag again - so a uniform 0.3-alpha pane vanished from the
ball while the scene drew it blended. The flag is read from the catalog
with the tint and kept in both preview shapes.

Add-on: a colour image used as an alpha mask was read through
image_rgba8, which turns a picture into what an albedo texture stores
(sRGB bytes), and the luminance of those encoded values was written as
coverage. A float mask at linear 0.25 came out as 137/255 instead of 64 -
enough to carry a cut-out across the 0.5 it is tested against. The mask
is now read in the values the shader works with (_shader_pixels): float
buffers as they are, 8-bit sRGB linearized as the Image Texture node
does, Non-Color data and the alpha channel as stored.

Run in a background Blender on the add-on as it stands in this branch:
linear 0.25 float mask -> 64, 8-bit sRGB 0.5 -> 55, Non-Color 0.5 -> 128,
alpha channel 0.3 -> 77; the same run covers the two add-on fixes merged
from #4 (Normal Map node space, resampler edge). The preview change was
not looked at in the material editor window.

* test: the scripted editor answers the resource status the tools now ask for

--probe-editor-tools drives the tool methods against a stand-in editor.
Since the resource editor became a target, a tool reads ResourceStatus
to learn which editor it is reporting on; the stand-in had no answer and
the save cases stopped there. It now hands back an empty status, which
names no target, so the map's status is used. 23/23.

* fix(cars): a clone or a substitution that fails takes itself back; resource_open stages what was asked for

Found reviewing this branch the way #3-#5 were reviewed.

car_clone deleted a working copy before it had checked anything. A folder
under the new name with no archive beside it was removed as "left behind
by an attempt that never produced its archive" - ahead of the checks on
the title and the vehicle table, so a clone refused a moment later had
already cost whatever the folder held (a parked car, work never built).
Such a folder is now a refusal that names it, and nothing is deleted.

Clone and substitute wrote several game files with no way back. A clone
adds rows to shared working copies and then packs five or more archives;
any pack failing (the game holding a file, a full disk) left a vehicle
row with no archive, tables written and not packed, and a name that was
now "taken", so the clone could not be tried again. A substitution that
failed on the winter archive left summer substituted and winter stock,
with the target's working copy already deleted. Both now keep a
GameWriteJournal: rewritten files get their bytes back, new files and
folders are removed, a replaced archive is restored from the backup taken
of it a moment earlier (and that backup dropped), and a working copy that
has to make way is moved aside until the operation has succeeded. The
refusal says the operation was taken back and names anything that could
not be.

resource_open answered success with the previous archive on the stage.
The resource editor's catalog is a walk of the game folder made when the
window opens; an archive made since - a car cloned a moment ago, which is
the workflow car_clone recommends - was never found, the request waited
for good, and the tool's "something is loaded" test was met by the car
already there. car_tuning_set then wrote into that car. The window now
walks again for an archive it does not know (once per archive), and the
tool waits for the archive it asked for and fails, naming what the stage
shows, when it does not arrive. An archive with nothing to draw no longer
runs out the whole timeout.

--probe-car-clone 73/73 (8 new: every kind of journal entry undone on
scratch files). --probe-car-clone-rollback (new; unlike the others it
WRITES to the install and restores it): the real Clone with the winter
pack blocked - refused onto an existing working copy without touching it;
after the failure the summer archive, both folders, vehicles.tbl, the
paint and traffic tables, cover points, manifest and text are as they
were, tables.sds and ingame.sds unpacked with no backup left, and the
same clone can be tried again. 11/11. resource_open checked live: a car
archive copied into pc\sds\cars after the window opened is staged, by
path and by bare name. The substitution's rollback is covered by the
journal checks only, not by a staged failure against the install.

* fix(import): a failed import leaves nothing behind; redo gets its shared block and its textures back

Found reviewing this branch the way #3-#5 were reviewed.

An import that failed after the frames were copied left them in the
scene. The copy is made first, then its textures are carried, its rows
built and its collision read; a failure in any of those answered "the
import failed" with the frames still in the resource - in the next save,
with no row in the tree to delete them by and no undo entry. The catch
also named five exception types, so a collision file that does not decode
went past it as a tool error, same leftovers. Import now takes the copy
back out when it fails before the edit is on the undo stack, catches
whatever is thrown, and says in the refusal that nothing was left. Edits
of one import that were already applied when a later one throws are
undone again; an actor already in the pack is removed; GPU meshes made
for rows that will never be shown are released. A failure AFTER the edit
is registered (writing the note that lets the next import share geometry)
no longer reports the import as failed.

Redo of a copy that shares a geometry block threw on the next save. A
second import of the same object draws from the first one's block and
lists none of its own. With the first copy deleted and the second import
undone, a save prunes the block; Reattach put back only the blocks the
copy lists, so the redone mesh named a block the resource did not have
and the save after it died in UpdateFrameData (KeyNotFoundException).
Reattach now also re-registers what its meshes point at, as
DetachedFrames does.

A save swept textures that undo could still bring back into use. Carried
textures no material of the scene names are removed at save - but the
import can be redone and a deleted object's delete undone, and neither
carries anything again: the object came back naming textures that were
gone from the working copy and its manifest. Swept textures are now
parked - out of the manifest, into illusion_parked\ under a name no scan
for *.dds answers to, with their manifest entries kept field for field -
and the next sweep that finds the scene naming one puts it back. What an
earlier run of the program parked is dropped at the first save of the
next; a texture carried again while parked wins over the parked copy. A
file that cannot be moved leaves the texture where it is for the next
save instead of failing the save.

--probe-object-transplant hill shops\harry.sds 50/50 (12 new): sweep with
the objects in / undone / saved twice / redone (same bytes, same manifest
entry, MIP companion with it) / swept again / carried again while parked
/ left by an ended run; the shared block after delete + undo + save +
redo, saved and read back. Without the Reattach change the same probe
dies with the KeyNotFoundException. Live, against the editor: a source
whose collision file is cut short - before the change every later import
under that name answers "already taken"; after it the same refusal twice
and no rows. A scenery import (250 hulls) and an actor import still work,
with undo, redo, undo. Not checked: the failure paths inside ImportPlaced
after the pack took the actor, and a failing GPU mesh.

* test(import): the carried-again check asks for the textures, not for their count

Higher in the stack a texture can also be borrowed from a third archive, so
the second carry brings more than the three objects brought the first time.

* fix(import): the lesser findings of the self-review - carry, links, names, numbers, thumbnails

The second half of the review of this branch. One commit because the
pieces lean on each other; by finding:

Carry. A refused or failed import now takes its carry back: the working
copy's manifest, prefab containers and carried register are noted before
the carry and restored, and the files the manifest gained are removed
(ArchiveCarry.Note / TakeBack). It used to leave textures, item
descriptions and a prefab entry behind for an object that never arrived -
the pack refuses an actor only after the carry. The texture a mesh names
itself (OMTextureHash) is carried, and counted as in use by the save-time
sweep. A MIP companion is copied before the texture that needs it, and a
texture whose companion cannot be brought is not brought. RemoveEntry
writes the manifest through a temp file, as AddEntry did.

Object-to-hull link. The hulls an imported object was given are found by
distance to the object's box, not its pivot - a district mesh's pivot can
be metres from its geometry, and its hulls were then never linked. A move
typed into the Transform panel takes the hulls along (it went through
RecordTransform, which knew nothing of them; past 5 m they were lost for
good and a later delete left them in the .col). Renaming the object moves
its record. The record is written by an edit of the import (and of the
duplicate), so undo takes it back out of the file. Links are worked out
once per operation for the whole selection instead of once per node, each
time re-reading the file and walking every object and placement.

Source. object_import takes `occurrence`: the n-th thing of that name in
the source (actors first, then frames - the ones that draw before helpers
of the same name), and reports how many there are. Position and heading
must be finite. The source archive is kept between imports from it (let
go after a minute, re-read when its working copy changed) instead of
being read whole for every object. "N hull(s) of its own" is now worded
as what it is - the hulls standing inside its box - in the result and in
the tool description.

Prop thumbnails. The archive is read on a pool thread (Stage) and only
drawn on the UI thread (Draw); the tick is guarded, since nothing above a
DispatcherTimer catches; the kept picture is keyed by the working copy's
time stamp as well and replaces the older ones; the held scene is let go
when the tab has all its pictures; texture folders no longer accumulate
across archives (TextureLibrary.ClearFolders).

--probe-object-transplant 53/53 (the take-back on a scratch copy; an
OM-named texture through carry, sweep, park and return).
--probe-object-import-live (new; a real window with `hill` loaded, the
district's working copy compared with its state before and put back):
30/30 - typed move and turn with undo/redo, drag, rename and its undo,
delete and its undo, everything undone leaves no record, occurrence past
the end, a source whose collision is cut short refused twice with the
same answer, a door the pack refuses leaving manifest and folder as they
were. --probe-prop-catalog 9/9, --probe-editor-tools 23/23.
Not checked: the Props tab filling in a window (only Stage/Draw are), a
companion that fails to copy, hulls linked through the box on an object
whose pivot really is far off (the probe's object has it close).
Not changed: "its own hulls" are still chosen by position - an archive
has nothing else to go by; item descriptions and the prefab entry of an
UNDONE import still stay in the working copy.

* fix(cars): the lesser findings of the self-review - target, notices, tuning values, builds that drop entries

The second half of the review of this branch, by finding:

Notices and Blender on the resource editor. Its notices went to its own
banner only, while editor_notices and the Blender tools read the
application's log: blender_push answered "no push arrived" for a push
that had landed, and the line car_tuning_set posts was lost. The window
now writes the log as the map editor does. blender_open counted the MAP's
Blender objects whatever the target and ran out its timeout on a session
opened in the resource editor; it asks the target.

Target. editor_open_area, object_import, actor_import and
editor_mirror_winter make the map the target, as resource_open makes the
resource editor: left where it was, the find/select/delete/save/build
after an editor_open_area went on acting on the car on the stage.
car_tuning_set makes the resource editor the target - its undo entry and
build list are that editor's. EditorStatus says which (Target).

Open editors. car_clone and car_substitute are made from the working copy
on disk: a source open in an editor with unsaved edits is refused. A
substitution whose target is loaded in an editor is refused - its working
copy is replaced under that editor. resource_open of the archive already
on the stage loads nothing (a reload emptied the undo history), and the
two cases the window answers with a dialog - an archive another editor
has loaded, a save that fails - are answered before it is asked: a tool
call that waits for a click never returns.

Re-keying. No root frame of the model's name, no prefab entry, entity
data filed under another name, no buffers: each was a note while the
archive was packed and the car registered. They are failures; the clone
or the substitution is taken back.

Tuning values. A float that is not finite and an integer wider than its
field are refused where the value is written (TuningEditing.Set), so the
Tuning tab is covered too.

Dropped entries. PackSds removes manifest entries whose file is missing
and said nothing. PackResult.Dropped carries them: both editors post them
as an error notice (never silenced), editor_build and the clone list
them, and archive_build - the tool for hand edits - refuses such a build
unless dropMissing is passed, since the entry is gone from the manifest
for good. MissingEntries asks without packing.

Said, not changed: a clone is registered in the main game's tables only -
the three story DLCs ship an ingame.sds of their own, and traffic is
added to CARM* tables only; the result's notes name both. The long file
work of clone/substitute/build still runs on the UI thread (it keeps the
editors from touching the files meanwhile); what was cut is the scratch
extraction MemoryFor repeated on every build of an archive that has no
shipped figures.

--probe-car-clone 75/75 (a car not keyed by the name given is a failure;
a working copy short of a file says which). --probe-car-clone-rollback
11/11, --probe-editor-tools 23/23, --probe-object-import-live 30/30.
Live, on a probe copy of jeep.sds removed afterwards: NaN, Infinity,
1e40 and an integer past its field refused; the tuning notice in the
log; the same car opened twice keeps undo; the target after
editor_open_area and after car_tuning_set; resource_open of the district
the map has loaded answered at once; clone and substitute refused
against the open car; archive_build refused, then built with dropMissing.
Not checked: blender_open/blender_push with the resource editor as the
target (no Blender in the run), the Build dialogs' new line in a window.

* fix(import): review of #6 - links by place, blocks shared only when alike, the Props tab paged

What the review of this branch found that the commits before this one had
not already answered (the failed-import rollback, the swept textures and
the numeric-edit path are in 56f5f8d and 45236da).

A collision link is a placement, found by its place. The record kept one
hash per hull: two placements of the same hull collapsed into one entry
(Distinct), a resize re-cooked the hull under a new hash the record did
not learn, and a placement was looked for within 5 m of the object's
pivot, which says nothing about where a placement's origin is. Each entry
is now one placement - the hull and where it stood in the object's own
space when the link was made - and it is found where the object's matrix
now puts that point, which every move, turn and resize the two make
together leaves unchanged. The same hull twice is two entries; a resize
rewrites the hashes in the record as an edit inside the same undo step;
a record written before this (bare hashes) is read as before and matched
by nearness to the object's box.

A geometry block is shared only when it draws the same way. Matching
buffers and decompression values was taken as "the same block", so a
second import inherited whatever had been done to the first copy's block
since - a draw distance set to 0, and an object that does not draw. The
draw distance, vertex layout and count and both capsules are compared
(FrameLOD.DrawsLike); the buffers are still shared, the block is copied
when it differs.

The Props tab makes cards a page at a time. A wrapping panel does not
virtualize, and the whole catalog - 2,900 objects here - was given cards
at once, about two seconds of the UI thread on opening and on every
filter change. 120 cards, and the next 120 when the end of them comes
within reach; pictures go to the cards in view first. The picture timer,
stopped when the tab is hidden, starts again when it is shown - a catalog
already loaded had nothing to restart it.

--probe-object-import-live 36/36 (6 new: a resize gives the hull a new
hash and the record follows, undo puts both back; a second placement of
the same hull nine metres from the pivot is the object's too, and both go
with it). --probe-object-transplant 54/54 (a copy whose draw distance was
changed does not lend its block). --probe-prop-catalog 12/12 (the tab in
a 330x700 window: 120 cards for 2928 objects, 240 after scrolling to the
end, pictures resume after hide and show).
Not checked: a record in the old format on a real working copy.

* fix(cars): review of #7 - the scratch clone takes itself back, manifests that do not read, undo re-enlists the archive

What the review of this branch found that c4b33d4 and 64b4e9b had not
already answered (the rollback of Clone and its packs, the stale catalog
behind resource_open, the modal dialog on the tool path and the
non-finite tuning numbers are in those).

CloneExtracted is whole or nothing by itself. The rollback lived in Clone;
the clone on working copies, called on its own, still wrote the vehicle
row before the title, the paint row and the traffic slots, and a failure
at any of them left a car registered by half and its name taken. It now
keeps the journal itself (Clone passes its own in, to add the packs to
it). The journal no longer tries to put back a file that was noted and
never written - with the text table held open, that was the one thing
the undo reported it could not restore.

The M2O export does not write over a manifest it cannot read. An existing
vehicles.json or package.json that does not parse was taken for 'none
yet' and replaced with this one car - after the archives had been copied.
Both are read before anything is written; one that is there and does not
read is a refusal that leaves the folder as it was.

Undo and redo of a value that lives in the working copy put the archive
back on the build list (WorkingCopyEdit, for tuning and effect edits, in
the panel and in car_tuning_set). A Build takes the archive off the list;
an undo after it changed the working copy and the next Build packed
nothing, leaving the game with the value that had been undone.

--probe-car-clone 77/77 (a clone whose text table is held open is refused
and leaves the vehicle table, the ingame tables, the text and the folders
as they were; the same clone then succeeds). --probe-car-m2o 24/24 (a
vehicles.json, then a package.json, that does not read: refused, both
files and the folder untouched). --probe-car-clone-rollback 11/11.
Live, on a probe copy of jeep.sds: set Mass, build, undo - the archive is
pending again; redo - still pending.

* fix(props): the picture cache of an earlier build is left alone

45236da gave a prop's kept picture a name that carries the state of its
archive, and on first use deleted every picture under the old name as
something nothing reads. A build from before that change, in use beside
this one on the same folder, reads exactly those: its Props tab lost all
2,928 pictures the first time a probe of this branch ran. Only a prop's
own pictures from earlier states of its archive are dropped now.

--probe-prop-catalog 12/12; 2,928 old-format pictures still there after it.

* fix(bridge): what a second look at 838c69a found

838c69a made the texture files of a push part of its undo and put a
refused material's files back. Read again before it went up:

Undo removed a texture the push had introduced. The material that names
it is created by an edit of its own and outlives the push's entry, and
Blender - told the push arrived - sends pixels again only when the image
changes: after one Ctrl+Z the texture was in no archive, and the next
push put the material on the object with nothing to draw it from. The
undo entry takes back pictures that were REPLACED; an introduced one
stays with its material.

Undo and redo did not put the archives back on the build list. Push,
Build, Ctrl+Z: the folder had the old picture, the game's archive the new
one, and Build answered that there was nothing to build.

A push that only repainted a texture was dropped from the history by the
first unload of any district: its entry names no objects, and 'all of its
objects have left the scene' is true of none.

A write that failed was remembered as done. The name was noted before the
write; the next object of the push sharing the image was handed it as
success and its material named a file that was never made. Noted after.

A texture half written - the entry replaced and its top level, or the
manifest, not - was caught with nothing on record to put it back from. It
is put back before the failure is passed on, and neither that nor
AtomicFile leaves its .tmp beside the target any more.

An object is refused before any of its materials is touched: every slot
is checked first for what can be refused without writing (normal map
space, images that did not arrive whole). A bad second slot used to be
found after the first slot's pictures had been replaced. A texture that
cannot be copied into the object's archive refuses the object as well.

--probe-bridge-material 61/61 (3 new: the two-slot object; a texture held
open refuses both objects sharing the image and leaves the picture, the
manifest and the folder as they were). --probe-material-editor 65/65.
The undo entry itself (introduced texture kept, archives re-enlisted, the
entry surviving an unload) is not covered by a probe - it lives behind
the editor's history.

* fix(mcp): a layer is not switched off over unsaved edits; moves and imports take finite numbers

A second look at f12155b. editor_open_area was given a guard against
dropping unsaved edits, and view_set was left without one: switching the
crash (or collision) layer off unloads it, and an unsaved move of a crash
copy went with it, undo entry included - the same loss by a shorter road,
and an easier one to take now that scene_find hands out crash copies.
Refused while the scene has unsaved edits, unless discardUnsavedEdits is
passed; switching a layer on, or off with nothing unsaved, is as before.

object_move and actor_import took a position or an offset as it came: a
number too large for a float arrives as Infinity and was written into the
transform. They refuse what is not finite, as property values already do.

--probe-editor-tools-live (new; a real window with a district loaded,
nothing saved) 12/12: both refusals of non-finite numbers; the layer off
and on with nothing edited; a crash copy moved, the layer refused, zones
still switch on, the layer off once the edits are given up.
--probe-editor-tools 23/23, --probe-mcp 66/66.

* fix(mirror): a second look at 3696630 and a3b5f73

Read again before they went up.

A material block worn by two objects was settled twice. The mirror writes
winter's hashes into the block, so the second wearer was resolved from
them as if they were summer's - counted as a slot the modder had
re-pointed, or substituted again where a winter hash is also a summer
one. The wearers of each block are gathered first and the block is
settled once, from summer's hashes, by whichever wearer the pair has an
answer for.

A mesh that existed only in winter …
Segfaultd pushed a commit that referenced this pull request Oct 7, 2026
…#9)

* feat: preview a material on the context mesh slot in the Material Editor

Local work found uncommitted in the working copy (base a55dc22, v0.3.1):
sphere/mesh preview toggle, the slot label on the assign panel, and the
SDK pin moved to 10.0.302.

* feat: turn a material made in Blender into a game material on push

A push used to refuse any slot whose material had not come from the
toolkit. A Blender-made material now travels with the image wired into
its Principled Base Color and becomes a real game material:

- addon: sends the image as RGBA8 (top-down, resampled to powers of two)
  once per push however many slots use it; the ack stamps the hash back
  on the datablock so later pushes send pixels only when they changed
- DdsEncoder: DXT1 with a full MIP chain and the stock header, written
  as a single Texture entry (HasMIP 0) - the shape 2809 stock city
  textures ship in, so no companion Mipmap entry is needed
- AuthoredMaterialResolver: fills in the hash before the mesh path runs;
  binds by name when the game already has the material, otherwise writes
  the texture into the object's archive (file + manifest) and creates a
  default-preset material with it in S000 (one undo entry)
- a re-push with new pixels rewrites the texture in place and the
  renderer reloads it (TextureLibrary.Invalidate)

Probe: --probe-bridge-material [district] [push.ilx] - encoder, resolver,
re-push, bind-by-name, refusal, and optionally a container written by
the addon itself.

* fix: stop the Material Editor throwing while its XAML loads

The Sphere toggle is checked in markup, so its Checked handler ran before
the Mesh toggle and the preview existed and the window died with a
NullReferenceException on open. The handler now waits for both.

The probe read the assign BUTTON's visibility; the button is hidden
through its panel since the mesh-preview change, so it reads the panel.
--probe-material-editor: 65 passed, 0 failed (was 50 + an exception).

* fix: live-test the Blender material push and close what it found

A live probe drives the real viewport and a real bridge session
(--probe-bridge-material-live): an off-screen D3DImageHost loads an
archive, opens a mesh in a running bridge Blender and waits for an
object made there, wearing a material made there, to be pushed back.
It checks the scene, the renderer, a re-push with a changed image,
undo/redo, Save, a reload from disk and a pack, then restores every
file. 21 passed against Blender 5.1.2.

What it found:
- image resize went through Image.copy().scale(), which hands back the
  blank original of a painted or generated image - the texture arrived
  black. The addon now resamples the pixel array itself.
- a rebuilt mesh's parts carried no material hash, so a later material
  edit (or a texture rewritten by a push) could not find them to
  re-resolve. The hash and tint now ride with the part, as on a mesh
  loaded from disk.
- Blender keeps the hash of a material the library may have lost
  (creation undone, toolkit closed without Save). With pixels it is
  made again; without them the push is refused once and the ack tells
  the datablock to forget, so the next push arrives complete.

* fix: store large textures the way the game does, and stop Build losing resources

A Blender-made material came out BLACK in game. The object was there and
the material was sound (same shader, flags and sampler states as 1888
stock materials) - the texture was not in a shape the game loads.

- From 256x256 up the game stores a texture split: the top level alone
  in a Mipmap entry, everything from half resolution down in the Texture
  entry with HasMIP=1 (6747 split textures surveyed, no exception; no
  stock mip-chained texture is wider than 128 on its short side). The
  encoder wrote a 1024x512 texture whole. DdsEncoder.Encode now returns
  the pair, and ArchiveTextureWriter writes both files and both entries.

Two packer bugs found while comparing the rebuilt archive with stock:

- TextureHandler charged a split texture only its own payload. Stock
  charges the Texture entry its payload PLUS its MIP companion's and
  leaves the Mipmap entry at zero (381 of 381 in italy.sds), so every
  Build under-reported the archive's video memory by the size of all its
  top levels - 6 MB for italy.
- PruneMissingEntries joined a rooted resource name ("/missions/...")
  with Path.Combine, looked for the file at the drive root, and unsaid a
  resource that was present. italy.sds lost its AudioSectors on Build.

--probe-bridge-material: 30 passed (split layout, the memory figure
against the files on disk, pruning with a rooted name).

* fix: give a created material the two fields the stock ones carry

The billboard was still black in game after its texture was stored
correctly. The material had been compared with stock on shader, flags,
sampler states and parameters - through a view that does not show every
field. Two were left at zero by the Default preset:

- Unk0: 128 on 1837 of the 1929 stock materials on that shader
- the diffuse sampler's TexType: 2 on 1947 of 1951 (0 on four)

Material_v57's Default preset now sets both, which also covers the
materials the glTF import creates. v58 (Definitive Edition) is left
alone: there is no DE install here to measure it against.

The probe compares a created material with the majority of stock
materials on its shader in these fields, so a preset that drifts from
the game's own records fails here instead of in game.

* fix: rename a probe local that clashed with one further down

The previous commit did not build: the new stock-record check declared
'made', which the addon-container section of the same method already
uses. --probe-bridge-material: 25 passed.

* feat: carry a Blender material's normal and specular maps into the game

A Blender-made material arrived with its Base Color image only. It now
also brings the image behind its Normal Map node and the one driving
its specular level, and becomes a normal-mapped game material.

Chosen from a survey of the stock library, not assumed:
- shader 5159568776351604322 (S000 + S001, parameter D013): 1209 stock
  materials, drawn on the P|N|T|UV0 declaration a pushed mesh already
  has. Its S001 is one combined texture - normal X and Y in red and
  green, the SPECULAR level in blue (stock "...NS" textures: red/green
  127 +- 10, blue 30..240), DXT1 like 97 of the 120 sampled.
- NormalSpecularPacker builds that texture from the two Blender images
  (either may be missing) and inverts green: Blender's normal maps have
  green up, the game's bitangent follows texture V, which runs down.
- MaterialPreset.DiffuseNormal creates the record in the commonest
  stock shape of that shader - Unk0, flags, both samplers' TexType and
  states, D013 - and the probe compares it field by field with the
  majority of the 1209.
- Blender's roughness and specular level become D013's power and level;
  the defaults (0.5 / 0.5) land on the commonest stock pair, 16 / 0.3.

A material that gains or loses its normal map needs another shader, so
it is replaced under the same name - the hash meshes refer to does not
move. The resolver now talks to an IAuthoredMaterialHost (create,
update, replace) instead of two callbacks; the application's host
records each on the undo history.

Also: binding a texture to a sampler sets its TexType to 2 (a slot
added in the Material Editor started at 0, which samples nothing in
game); two materials sharing an image share one texture file.

--probe-bridge-material: 33 passed. Driven live through the real
viewport against a windowless Blender: the billboard in italy took a
new material with all three maps and shows the relief in Render mode.

* style: dotnet format the preview viewport's mesh builder

* feat(mcp): tools that drive the running map editor

The MCP server could read game files but not touch the editor, so a client
working on a scene had to fall back on UI automation for everything the
window does. These tools close that gap:

- editor_status / editor_list_areas / editor_open_area (opens the editor
  from the launcher and waits for the area to finish loading)
- scene_find (by name, kind, or a world box tested against a mesh's
  triangles rather than its bounds) and scene_select
- blender_open / blender_end, and editor_notices for what a push reported
- editor_save / editor_build, object_move, scene_delete_selected,
  editor_undo / editor_redo
- camera_get / camera_look_at / camera_set / camera_frame_selection,
  view_set (shading mode and layers) and viewport_screenshot

Illusion.Mcp gets the IEditorSession seam; the application implements it
over the open MainWindow. Supporting changes: ViewportControl.CaptureFrame,
LookAt and LookFrom, DistrictStreamer.IsBusy, LauncherWindow.OpenMapEditor,
and a notice log the banner feeds.

* feat(mcp): blender_push, and scene_select with no names clears the selection

blender_push sends the bridge's request_push, waits for the push to land
and returns what the editor reported, so a client that edited the session's
objects in Blender gets the outcome in the same call instead of polling the
notice log. An empty scene_select clears the selection: its outline is
drawn through walls and would otherwise sit in every screenshot.

* fix(bridge): refuse a pushed mesh over 65535 vertices instead of writing 32-bit indices

A rebuild that came to more than 65535 split vertices switched the level's
index buffer to 32-bit. The toolkit's viewport draws that; the game does
not — an interior of 65 981 vertices came up as triangles smeared across
the district, textures flickering, while the editor showed it whole.

The push is now refused with the vertex count, so the modeller can split
the object. A rebuild always writes 16-bit indices, and a level that still
carries a wide buffer from before is rebuilt on its next push whatever
that push changed, which is what puts it right.

scene_find reports each mesh's vertex and triangle count, so the size of
an object can be checked before it is built into an archive.

* feat: bring a light into a district that has none — actor import across archives

A city district ships with no lights of its own: its interiors are lit by
LightEntity actors, and the only way the editor could make an actor was to
copy one already in the pack. ActorsFile.Import copies an actor out of
another archive's pack, with its own copy of the behaviour row it points
at; the editor wraps it as one undoable edit and gives it a row in the
tree, creating the entity type's section when the district never had one.
Only an actor that places no object of its own scene can travel — a light,
a sound — and the copy is linked to a frame named after itself, which is
the frame such an entity makes.

A light keeps its transform twice: in the actor record and at the head of
its behaviour blob (every shipped light's x appears exactly twice). The
record is what the editor moves, so the pack now carries the translation
over into the blob when it is written; an untouched pack still writes byte
for byte.

MCP: actor_import, scene_duplicate_selected, object_properties and
object_set_property (the property panel as data, undoable writes), and
decode_actors can list each actor's behaviour fields.

* Keep a light's inverse matrix and clip box in step with where it stands

A light's behaviour blob ends with the inverse of its world matrix, and the
game works the light out through it. A light imported from another archive
(or moved in the editor) kept the old inverse: every field read correctly,
the light stood in the right place, and it lit nothing.

SyncLightFrame now rewrites the inverse whenever it is out of step with the
head matrix, carries the clip box along by the distance the light moved, and
runs on import as well as on write. Untouched packs still write byte for
byte: shipped lights are within 1e-4 of a true inverse and are left alone.

* Show and edit a mesh's draw distance in the property panel

Each level of detail carries the distance at which it stops being drawn,
stored squared. It is the one cost control a static object has - the game
culls by distance and view cone only - and an object made through the
Blender bridge starts at a million metres. The panel (and so the MCP
object_set_property) now lists one draw distance per level, in metres.

* fix: touch a light's row only when the light has moved

The light sync rewrote any inverse matrix that was out of step with the
head matrix. The install has 72 such rows that shipped that way (three
Joe's Adventures archives carry zeroes or noise there), so re-saving those
untouched packs changed them: --probe-act-relayout, --probe-actor-props and
--probe-native-misc fell to 1047/1050.

Now a light whose record and head translation agree is left alone, and a
moved light gets a new inverse only if the one it had was a true inverse.
The clip box and inverse are also written to the row's named fields: on
write the core pokes every named field back over the blob, which put the
imported light's old box straight back.

--probe-act-relayout gains the import: a light from one archive into a
pack of another, checking its own row, matrix, inverse, clip box, the
writer round trip and a refused duplicate name. 1050/1050 packs re-save
byte for byte again.

* docs: the editor tools, the Blender material push and their limits

README: the MCP section lists the 25 editor tools and says how they relate
to the editor's own undo, save and build; the bridge section covers pushed
materials and the 65535-vertex refusal; the property panel mentions draw
distances; Known limits gains opaque-only pushed materials and undrawn light
actors. --probe-mcp now expects the editor tools as well.

* feat: carry a Blender material's alpha into the game - cut-out and translucent

A material made in Blender was always opaque: its diffuse went to DXT1 and
its flags were the plain ones. Now whatever feeds the Principled BSDF's
Alpha - the image's own alpha, a mask image, or a value below 1 - is written
into the diffuse texture, which is then stored as DXT5, and the material's
render method picks the mode: Blended is a translucent surface, anything
else a cut-out.

The game has no separate shader for either on the shaders the bridge
creates materials for; the flag word decides. Measured over default.mtl on
the diffuse shader: 239 materials set Alpha and keep writing depth (fences,
signs, foliage), 323 set Disable_ZWriting on DXT5/DXT3 textures (overlays),
11 carry both. A cut-out gets Alpha and keeps CastShadows; a blend gets
Disable_ZWriting and drops CastShadows; every other bit is left alone.

- DdsEncoder: DXT5 (interpolated alpha block + the DXT1 colour block), with
  the header stock DXT5 files carry.
- MafiaMaterialCatalog: GetFlags / SetFlags; the editor's SetFlags is an
  undoable edit.
- The addon sends alphaMode with the material and puts the alpha it means
  into the image; an opaque material keeps its old signature, so nothing is
  resent for nothing.
- --probe-bridge-material: 40/0, with the encoder's alpha round trip, the
  flag words, and one material taken through cut-out, blend and back.

* feat(viewport): draw alpha-blended materials blended

The mesh shader alpha-tested every textured material at one half. That is
right for a cut-out and wrong for glass: a pane whose alpha is 0.3 was
clipped away entirely, so a translucent material - a stock one or one
pushed from Blender - was simply not there in the viewport.

A part now knows whether its material blends (Disable_ZWriting in the flag
word), and such parts are drawn in a pass of their own after the opaque
one: depth-tested, never depth-written, standard alpha blend, no clip. The
flag follows a material edit the same way the textures and the tint do.
Unsorted on purpose - a pane seen through a pane may composite in the wrong
order. Instanced props keep the alpha test.

* feat: mirror a district's edits into its winter archive

A district ships twice and an edit made to <name>.sds is simply absent
from <name>_z.sds. For most districts the two are one scene: measured on
uppertown, every shared resource (buffer pools, name table, actors,
collisions, item descriptions, prefab) is byte for byte the same, and the
frame resource has the same length and differs in 1740 places - each a
material hash swapped for its ^zima counterpart, 82 pairs. Textures aside,
nothing else differs.

SeasonMirror writes the summer working copy over the winter one with each
object's winter materials taken from the winter object of the same name
(and occurrence - names repeat), copies the shared files that differ, adds
the pools summer gained with their manifest entries, and adds the textures
the result names that winter lacks. The caller queues the winter archive
for Build.

Not every district is such a pair: 13 of 23 are, ten have a winter scene of
their own. The pair is therefore judged first on the archives as shipped
(each one's oldest backup, or the archive itself if never built), and a
district that fails is refused rather than overwritten.

- MCP: editor_mirror_winter.
- --probe-season-mirror: 11/0 on scratch folders built from one working
  copy, so it does not depend on what the modder did to the install - an
  untouched pair is left alone, a missing object arrives and winter is
  byte for byte whole again, a stranger is refused untouched; reports which
  of the install's pairs are twins.

* feat: carry objects from one archive into another

A district can now be furnished from the rest of the game: a door from a
shop, a chair or a plant from an interior, brought into the loaded area as
one undoable edit (MCP object_import).

- FrameTransplant copies a frame subtree across resources: frames re-homed
  (FrameEntry.MoveTo), geometry and material blocks deep-copied once per
  shared block, every buffer they draw from copied into the district's
  own pools under fresh names. Links inside the subtree follow the copies;
  the root becomes a prototype (an actor places it) or scenery anchored to
  the district's main scene, on the name table, standing where it is put.
- ActorsFile.Import takes an actor that places an object once that object
  has been carried, links it by the copy's name through a scene reference
  of its own, and can turn it.
- ArchiveCarry brings what the scene does not hold: the textures the
  materials name, the item descriptions the collision frames name, and the
  prefab entry the definition names (PrefabFile.Adopt, which keeps the
  container's size header right - the core writes it as it is handed it).
  Keys travel unchanged; what the district already has is shared.
- ObjectImportController shows the copy (tree rows, GPU meshes, actor row)
  and undoes it as one edit.
- A frame named by hash alone (weapon parts) kept a zero hash when copied:
  the copy constructor rebuilt the name from its empty string. Fixed for
  the duplicator and the prototype cloner too.

--probe-object-transplant: 30/0 - a door, a prop an actor places and a
piece of scenery go from shops/harry.sds into a scratch copy of a district,
keep their shape frame for frame and their buffers byte for byte, survive a
save and a reload (actors resolve, meshes decode from the district's pools,
prefab and item descriptions in the manifest), and an undo leaves the scene
byte for byte as it was.

* feat: a Props tab to drag stock objects into a district

The map editor's property rail gets a Props tab: the stock game's doors,
seating, tables, beds, storage, plants, lamps and decor, one card per
object with a picture. Drag a card onto the viewport to put the object
where it lands (on the scene's surface under the cursor), or double-click
it to put it in front of the camera.

- PropCatalog finds them in the extracted interiors and districts: every
  actor that places an object of its archive (door, physics prop, framed
  furniture), and scenery whose name says what it is and whose size is a
  piece of furniture. One card per object across archives; interiors are
  read first, so a card points at the stock source. The scan (~6 s) is
  remembered per archive and redone only for what changed.
- PropThumbnailRenderer draws each card's picture offscreen with the
  object's own textures and keeps it on disk.
- Scenery now brings collision: the source's hulls that stand inside its
  footprint, re-placed by the same move (CollisionCarry), or one cooked
  from its triangles when it had none - small props usually have none.
- A door carried out of its building gets ActorActionsEnabled: a shop's
  front door ships with the player's actions off, its shop script turns
  them on, and nothing does that where it lands.
- The import core moved from the MCP session into ObjectImportController,
  shared by object_import and the tab. A card from the loaded district is
  duplicated in place rather than refused.
- RefManager hands out ids atomically: the catalog parses scenes in the
  background while the editor loads districts.

--probe-prop-catalog: 7/0 - 2747 props in 6.3 s, 52 ms remembered, known
doors and chairs on their shelves, each shelf's first card gets a picture.

* feat: simplified hulls for imported scenery, and a props column

- A hull cooked for scenery that had none of its own is now its convex
  hull by default (ConvexHull: incremental, points snapped to a grid a
  fortieth of the object's size) - a coat rack goes from 1474 collision
  triangles to 22. Also a box (12 triangles) or every render triangle,
  chosen per import: object_import's 'collision' (auto / convex / box /
  mesh / none) and a chooser in the props panel. A flat thing has no
  convex hull and gets a box.
- The props panel moves out of the property rail into a column of its own
  left of the viewport: as tall as the window, sized with a splitter,
  shown and hidden by a Props button on the toolbar; the viewport's tool
  shelf moves with the viewport and is no longer covered.
- Textures an import carries are written down beside the working copy
  (illusion_carried.json, outside the manifest) and each save sweeps the
  ones no material of the scene names any more - an undone import, or one
  lost with a scene closed unsaved, no longer leaves megabytes behind.

--probe-object-transplant: 33/0 (+ the hull of a cube is the cube, a stock
model's hull holds all its vertices and faces out, a flat thing gets a box).

* feat: an imported object's collision moves and is deleted with it

Nothing in an archive ties a static object to its collision - the hull is a placement that happens to stand where the object stands. For scenery the toolkit carried in, the tie is now written down beside the working copy (ImportLinks, illusion_links.json, outside the manifest): the object's name and the hashes of the hulls it was given. A gizmo drag of the object drags those placements along (a scale mints a scaled hull as it does for any placement), and Delete takes them with it; a Delete that went through several editors is one Ctrl+Z (EditHistory.SquashSince). Stock objects are untouched: nothing ties them, and guessing by position would take a building's hull along with a bench.

* feat: a duplicate of an imported object gets copies of its collision

Duplicating scenery the toolkit carried in also places copies of the hulls it was given, where the copy stands, links them to the copy under its own name, and the whole duplicate is one Ctrl+Z. Linked hulls are now claimed one to one across the scene, nearest first and ties to the name that sorts first: a copy stands on top of its original with the very same hull, and dragging it used to drag the original's hull away.

* feat: save drops geometry nothing draws; a re-import shares geometry

- Save writes the buffer pools without the buffers no geometry block
  draws from - left behind by a deleted object, a sanitised block, a
  rebuilt mesh. Measured: none of the 130 archives as shipped carries one
  (--probe-geometry-sweep), the install's working copies had ~0.5 MB.
  Only the files lose them: memory keeps them, so an undo after a save
  brings the object back and the next save writes them again
  (SdsGeometrySaver.SavePools tracks what each pool file holds).
- Importing an object again draws from the buffers - and, when every level
  is there, the geometry block - its earlier import brought, the way the
  shipped scenes put many copies of one chair on one mesh. Where geometry
  went is remembered per source archive beside the working copy
  (ImportGeometry, illusion_geometry.json); a remembered buffer the scene
  no longer has is simply copied again.
- The bridge probes snapshotted only the pool they edited; a save now may
  rewrite others, so they snapshot and restore every pool.

--probe-object-transplant: 38/0 (+ imported twice shares, the memory
survives on disk, undoing copies keeps the first, a save leaves dead
buffers out of the files and writes them back when drawn again).

* feat(mcp): drive the resource editor; read and set car tuning

- editor_target switches the scene tools between the map editor and the
  resource editor's stage: find, select, properties, move, duplicate,
  delete, camera, screenshot, shading, save, build, undo/redo and the
  Blender tools follow it, and answer with that editor's status.
- resource_list searches the archive library (pc\sds by name or folder),
  resource_open stages one - from the launcher the way its tile does,
  beside the map editor as a second window - and waits for it to load;
  resource_status reports what is on the stage.
- car_tuning lists a car's entity-data tables (stock and tuned variants,
  labelled by mass and power) and their fields by band and element;
  car_tuning_set writes one, parsed by its kind, with the Tuning tab's
  bookkeeping: an undo entry, the archive on the build list, the tab
  re-read. A wheel's or gear's field can be named without its prefix
  together with its element.

Tried on shubert_38: 6 tables x 771 fields; Power, CenterOfMass and
Wheel0.Scale set, read back and undone. --probe-mcp: 66/0.

* feat(cars): clone a car under a new name; a Build no longer drops XML

- car_clone (MCP) / CarCloner: copies pc\sds\cars\<source>.sds and its
  winter twin to <name>.sds and renames what the name keys inside - the
  root frame and its actor link (and so the name table), the PREFAB
  entry (FNV64 of the name) and the entity-data storage (FNV64 of the
  name in lower case). Then registers the car: a vehicles.tbl row under
  a fresh id with the source's class, price and flags, its
  PaintCombinations row, its AiProps cover points, and - optionally - a
  slot in every traffic row (CARM*.tbl) that picks the source. Builds
  the new archives plus tables.sds and ingame.sds, backups kept.
- GameTable: an extracted .tbl as rows of cells, written back through
  the table codec. PrefabFile.Rekey, EntityDataStorageFile.Hash.
- Fix: PruneMissingEntries looked for an XML resource under its manifest
  name, but it sits on disk as name + ".xml" - so the first Build of an
  archive with XML resources (ingame.sds, tables.sds) unsaid every one
  of them, and the Script containers with them. XML is now found by its
  real file, containers are left alone.

--probe-car-clone: 33/0 (on scratch copies: refusals write nothing, the
rows and hashes the clone gains, the archives pack, nothing is pruned,
every XML of both table archives decompiles to the same text after a
pack). --probe-mcp 66/0, --probe-object-transplant 38/0.

* feat(mcp): archive_build packs one archive's working copy

For an edit made in the working copy itself - a script, a table file -
that no editor session tracks: packs the extracted folder back into its
.sds through SdsWriter.PackSds, keeping the usual timestamped backup.
editor_build stays the way to pack what the editors changed.

Used on pc\dlcs\cnt_jimmys_vendetta\sds_ru\missionscript\
fr_game_director.sds after a script edit: 8 resources before and after,
only the script pack differs (+301 bytes, the edit).
--probe-mcp: 66/0.

* fix: a rebuilt name table keeps the order it was loaded in

A cloned car sat in the garage with its name and no model. Two causes,
both found against the game:

- The name table was rebuilt in frame-index order. The order is
  authored, not derivable: every one of the 103 cars the game ships
  lists its root frame first, and only 34 happen to be in index order.
  The clone's table led with an effects frame. Each frame now carries
  the position the loaded table gave it (FrameNameTableOrder, set where
  the flags are linked), and BuildDataFromResource emits in that order,
  frames the table never listed after them by index. This is every
  rebuild, the Resource Editor's car save included.
- The game finds archives through its cached file list
  (%LOCALAPPDATA%\2K Games\Mafia II\Data\vfs.bin) and does not pick up
  a NEW archive on its own. GameFileIndex.Reset removes the cache, the
  way Steam's install script does; car_clone calls it and says so.

--probe-car-clone: 36/0 - the clone lists its root first; rebuilding
the name table of each of 103 shipped cars reproduces it byte for byte
(five tables that list a frame twice or name a frame the resource does
not hold are left out). --probe-nametable: 1331/1331 semantic,
960 byte-identical. --probe-object-transplant 38/0, --probe-mcp 66/0.
Whether the clone now shows in game is not yet confirmed.

* feat(cars): a clone gets buffers and a title of its own

- Buffers. The game keeps vertex and index buffers by name across every
  archive it has loaded, so a clone that kept the source's names would
  draw whichever shape streamed first once its model is edited - and so
  would the source car. Cloning now renames every buffer the geometry
  draws from, in the geometry blocks and in the pool files
  ("Shubert_38.Root.L0.VB0" -> "<name>.Root.L0.VB0"; a name that does
  not start with the model's gets the new one in front).
- Title. car_clone takes an optional title: one new string in the text
  table of every installed language (sds_<lang>\text\text_default.sds,
  tables\TextDatabase.dat - UTF-8 "KEY:TEXT" lines, id 60000017 is key
  00_60_00_0017), under an id from 60001000 + vehicle id that nothing
  uses, and the clone's vehicles.tbl row points at it. Without a title
  the clone shares the source car's name, as before. GameText reads and
  adds lines; the text archive is built with the others.

--probe-car-clone: 48/0 (the clone's buffers share no name with the
source's, every level of detail resolves, the bytes are the source's in
the source's order; the title is one added line and nothing else, the
text archive packs, its XML survives the pack, nothing is pruned).
In game: the first clone showed in the garage with its model after the
name-table fix; this build of it is confirmed to start, not yet looked
at in the garage.

* Packer keeps shipped memory requirements; export a car as an M2O resource

A rebuilt archive stated payload sizes where the shipped one stated what the engine should budget (a car asked for 148 079 bytes of slot RAM against 170 232, and no 'other' RAM). The figures are now read from the archive as it shipped, kept beside the working copy (illusion_memory.json) and applied on pack; a clone carries its source car's. archive_build takes memoryFrom for a clone built before that.

car_export_m2o writes a resource folder for Mafia II Online: package.json, cars/<name>.sds (+ _z) and vehicles.json with the model, titles, source car, key hashes and the vehicle/paint table rows. Probes: --probe-car-clone (memory), --probe-car-m2o.

* feat(cars): build a car under another car's name

car_substitute replaces <target>.sds with the source car's model keyed by the target's model name (root frame, name table, prefab entry, entity data, buffers), keeps a backup and touches no table - for trying a car where models are spawned from a fixed list of names.

* feat(cars): the M2O car export follows the map export's layout; docs

Archives sit under sds/cars/ in the exported folder and each entry names the path the game loads it from (sds, winterSds), the way the map export names its patch targets. README: the car tools, memory requirements, the tool count.

* fix: an imported object brings textures its own archive does not hold

A weapon lying in a shop is drawn with textures of weapons.sds; the import looked for them in the shop's archive only, reported them as 'in neither archive' and the object stood white in the district. A texture the source lacks is now taken from whichever extracted archive holds it - the game itself ships the same texture in many archives - and the notice names where it came from.

--probe-object-transplant: 43/0 (+ a shop weapon's texture comes from weapons.sds byte for byte, none is written when no archive holds it).

* perf(bridge): read a level's index width without decoding the mesh

Review of #3. HasWideIndices decoded the whole level - every vertex
channel, the index array and, on a model, the skin - to read one field of
its index buffer, on every push, ahead of a path that decodes the mesh
again. It now clamps the level and looks at the index buffer directly.

--probe-bridge-rebuild 17/17, --probe-bridge-vertex 2/2,
--probe-bridge-resplit 6/6.

* fix(mcp): editor tools say no when the editor does, and find crash copies from the table

Review of #3, the findings in the MCP session.

editor_open_area dropped unsaved edits. Changing the area or the season
reloads the scene, which clears the edited frames, the unsaved list and
the undo history, and the tool only checked for a Blender session. A load
that would replace a scene with unsaved edits is now refused unless the
caller passes discardUnsavedEdits=true; asking for the area and season
already shown stays a no-op.

editor_save and editor_build reported success after a save that did not
complete. The viewport's save does not throw for a material library that
would not write or a car's working copy that was refused - it posts a
notice and carries on - so the tool had nothing to return but success, and
a build went on to pack archives whose materials were not on disk.
D3DImageHost.SaveEditsReport hands back what was left unsaved;
editor_save answers success=false with notSaved, and a build whose save
did not complete packs nothing and says why.

object_set_property could not rename. Base.Name is offered as an editable
HashName and the parser had no case for it. It has one now, with the
property panel's rule (an empty name is refused), and a rename to the name
the object already has is not committed as an edit.

Numeric properties took NaN and infinity. float.TryParse accepts "NaN",
"Infinity" and "1e100" (as infinity); a float and each component of a
vector now have to be finite.

scene_find did not see crash copies until their row had been expanded in
the tree, and a box query never returned one. They are now searched from
the placement table: by the copy's own label or its prop's name, and by
the prop's triangles at the copy's matrix. Only the copies returned get a
tree node.

--probe-editor-tools (new, no game install needed) drives the real tool
methods against a scripted editor and the parser directly: 23 checks.
Checked live in the editor on uppertown: 5 lamp copies found with no row
expanded, 288 triangles of one in a box round it, none 400 m above it; a
moved mesh blocks a load of another area and of the other season, the
same area is a no-op, discardUnsavedEdits=true loads. A failing material
write was not reproduced live - that path is covered by the scripted
editor only.

* fix(mirror): the winter mirror decides against the pair as shipped, not against the last mirror

Review of #5, the four findings in SeasonMirror.

Twins were told by size. Two archives passed as "one scene shipped twice"
when their shared pools were equal and their scenes the same length - and
a winter scene with one object moved 50 units is the same length. The
scenes are now parsed, winter's material hashes replaced by summer's, and
the two written back out: anything left that differs (a transform, a
parent link, a draw distance) means winter is a scene of its own and the
mirror is refused. The 13 stock twins still read as twins and the other
ten as not.

Everything else follows from reading that pair once (SeasonPair) and
deciding against it, where the mirror used to ask the winter WORKING COPY
what winter wore - which answers with whatever the previous mirror left:

- A slot the modder re-pointed in summer got its old winter material
  back. A summer material no object of that name wore when the pair
  shipped is now carried into winter as it is (Report.Reassigned).

- Namesakes were matched by position among objects of the same name, so
  deleting the first handed its materials to the second. The season's
  substitution is now looked up by name and SUMMER MATERIAL; position is
  only used when one summer material stands for two winter ones under one
  name, and only while the number of namesakes is what it shipped as.
  Otherwise the object keeps summer's material and is counted
  (Report.Ambiguous, raised as an error notice) rather than dressed in
  another object's snow.

- A texture an earlier mirror had brought was never refreshed: the bridge
  repaints an authored texture in place, and "winter has a file of that
  name" ended the matter. A file winter did not SHIP with is now compared
  and rewritten, its MIP_ companion with it (and removed when the new
  picture has none); a file winter shipped with is the season's own and
  is left alone.

Mirror() also refuses a winter folder that is not made of the pair's
meshes before writing anything, and editor_mirror_winter stops when the
save it starts with did not complete. Its result names slotsReassigned
and objectsAmbiguous in place of objectsReshaped.

--probe-season-mirror builds its own namesakes and runs every case above
on scratch copies: 23/23 on uppertown and oysterbay, 22/22 on italy,
21/21 on greenfield (not a twin, so the shipped-texture checks do not
apply).

* fix(bridge): a material push that can be undone, refused cleanly and shared across archives

Review of #4, all ten findings.

Texture files are part of the push's undo. A repaint under the same name
changes no binding, so the catalog recorded nothing and Ctrl+Z could not
bring the old picture back. The resolver now journals every file it
writes (what the name held before, what it holds now) and the push
carries a TextureFilesEdit in its one history entry: undo restores the
old files - or removes a texture the push introduced, manifest entries
included - and redo puts the new ones back.

Nothing is written before everything is checked. The diffuse texture was
on disk before the normal map had been looked at, and before the library
had accepted the material. All images are now read, validated and packed
first; what is written before a later file or the library refuses is
rolled back from the same journal.

A split texture is compared as two files. PickName compared only the
entry, which for a texture of 256 and up starts at half resolution: a
fine checkerboard and the grey it averages to are byte-identical there
(reproduced), so the second picture took the first one's name and its
write replaced the first one's top level. The MIP_ companion - and
whether there is one - is now part of the comparison.

A material shared by two archives is packed into both. The second object
of a push binds the new material by name, and an unchanged material
arrives with no pixels; either way an archive that had never held its
textures got none. The files are now copied from the archive that has
them (only for materials this bridge created - a stock material bound by
name copies nothing).

A renamed material keeps its hash across a shader change. Gaining or
losing a normal map deletes and recreates the record, and it was
recreated under the Blender datablock's current name - a new hash, with
every other mesh left pointing at the old one. It is rebuilt under the
name the library knows it by.

Reloads are coalesced. A push of a hundred objects sharing a material
asked for a hundred invalidate-and-rebind passes over every loaded mesh;
each texture is now invalidated once and each material that names one
rebinds once (ReloadTextureFiles).

A rebound part returns the textures it held. GpuMesh kept every lease
until dispose, so the library's retired copy of each repainted texture
stayed on the GPU for as long as the mesh was loaded. Leases are kept per
part and the superseded ones released once the part holds the new ones.

The mesh preview keeps the material's tint.

Add-on: a Normal Map node in Object or World space is no longer exported
as if it were tangent space - the material is refused by name with what
to change - and the resampler clamps its two neighbours independently:
upsampling [0, 1, 0] to four texels gave 0.875 at the left edge, now 0.

--probe-bridge-material 58/58 on eastside (30 new checks: each case
above, including twenty repaints of a texture a live GpuMesh draws
leaving nothing retired), --probe-material-editor 65/65,
--probe-bridge-rebuild 17/17, --probe-mcp 66/66. The undo entry itself
was checked at the file level (restore before/after), not by pressing
Ctrl+Z in the editor, and the two add-on changes were not run inside
Blender: the resampler was checked on the reviewer's numbers outside it.

* fix: the material preview blends what the scene blends; an alpha mask is sampled linear

Review of #5, the two findings outside the mirror.

The material editor's preview still alpha-tested a translucent material.
SphereMesh forwards a part's Blended flag, but the part the preview
builds for a material carried only its tint, and the mesh-shape preview
dropped the flag again - so a uniform 0.3-alpha pane vanished from the
ball while the scene drew it blended. The flag is read from the catalog
with the tint and kept in both preview shapes.

Add-on: a colour image used as an alpha mask was read through
image_rgba8, which turns a picture into what an albedo texture stores
(sRGB bytes), and the luminance of those encoded values was written as
coverage. A float mask at linear 0.25 came out as 137/255 instead of 64 -
enough to carry a cut-out across the 0.5 it is tested against. The mask
is now read in the values the shader works with (_shader_pixels): float
buffers as they are, 8-bit sRGB linearized as the Image Texture node
does, Non-Color data and the alpha channel as stored.

Run in a background Blender on the add-on as it stands in this branch:
linear 0.25 float mask -> 64, 8-bit sRGB 0.5 -> 55, Non-Color 0.5 -> 128,
alpha channel 0.3 -> 77; the same run covers the two add-on fixes merged
from #4 (Normal Map node space, resampler edge). The preview change was
not looked at in the material editor window.

* test: the scripted editor answers the resource status the tools now ask for

--probe-editor-tools drives the tool methods against a stand-in editor.
Since the resource editor became a target, a tool reads ResourceStatus
to learn which editor it is reporting on; the stand-in had no answer and
the save cases stopped there. It now hands back an empty status, which
names no target, so the map's status is used. 23/23.

* fix(cars): a clone or a substitution that fails takes itself back; resource_open stages what was asked for

Found reviewing this branch the way #3-#5 were reviewed.

car_clone deleted a working copy before it had checked anything. A folder
under the new name with no archive beside it was removed as "left behind
by an attempt that never produced its archive" - ahead of the checks on
the title and the vehicle table, so a clone refused a moment later had
already cost whatever the folder held (a parked car, work never built).
Such a folder is now a refusal that names it, and nothing is deleted.

Clone and substitute wrote several game files with no way back. A clone
adds rows to shared working copies and then packs five or more archives;
any pack failing (the game holding a file, a full disk) left a vehicle
row with no archive, tables written and not packed, and a name that was
now "taken", so the clone could not be tried again. A substitution that
failed on the winter archive left summer substituted and winter stock,
with the target's working copy already deleted. Both now keep a
GameWriteJournal: rewritten files get their bytes back, new files and
folders are removed, a replaced archive is restored from the backup taken
of it a moment earlier (and that backup dropped), and a working copy that
has to make way is moved aside until the operation has succeeded. The
refusal says the operation was taken back and names anything that could
not be.

resource_open answered success with the previous archive on the stage.
The resource editor's catalog is a walk of the game folder made when the
window opens; an archive made since - a car cloned a moment ago, which is
the workflow car_clone recommends - was never found, the request waited
for good, and the tool's "something is loaded" test was met by the car
already there. car_tuning_set then wrote into that car. The window now
walks again for an archive it does not know (once per archive), and the
tool waits for the archive it asked for and fails, naming what the stage
shows, when it does not arrive. An archive with nothing to draw no longer
runs out the whole timeout.

--probe-car-clone 73/73 (8 new: every kind of journal entry undone on
scratch files). --probe-car-clone-rollback (new; unlike the others it
WRITES to the install and restores it): the real Clone with the winter
pack blocked - refused onto an existing working copy without touching it;
after the failure the summer archive, both folders, vehicles.tbl, the
paint and traffic tables, cover points, manifest and text are as they
were, tables.sds and ingame.sds unpacked with no backup left, and the
same clone can be tried again. 11/11. resource_open checked live: a car
archive copied into pc\sds\cars after the window opened is staged, by
path and by bare name. The substitution's rollback is covered by the
journal checks only, not by a staged failure against the install.

* fix(import): a failed import leaves nothing behind; redo gets its shared block and its textures back

Found reviewing this branch the way #3-#5 were reviewed.

An import that failed after the frames were copied left them in the
scene. The copy is made first, then its textures are carried, its rows
built and its collision read; a failure in any of those answered "the
import failed" with the frames still in the resource - in the next save,
with no row in the tree to delete them by and no undo entry. The catch
also named five exception types, so a collision file that does not decode
went past it as a tool error, same leftovers. Import now takes the copy
back out when it fails before the edit is on the undo stack, catches
whatever is thrown, and says in the refusal that nothing was left. Edits
of one import that were already applied when a later one throws are
undone again; an actor already in the pack is removed; GPU meshes made
for rows that will never be shown are released. A failure AFTER the edit
is registered (writing the note that lets the next import share geometry)
no longer reports the import as failed.

Redo of a copy that shares a geometry block threw on the next save. A
second import of the same object draws from the first one's block and
lists none of its own. With the first copy deleted and the second import
undone, a save prunes the block; Reattach put back only the blocks the
copy lists, so the redone mesh named a block the resource did not have
and the save after it died in UpdateFrameData (KeyNotFoundException).
Reattach now also re-registers what its meshes point at, as
DetachedFrames does.

A save swept textures that undo could still bring back into use. Carried
textures no material of the scene names are removed at save - but the
import can be redone and a deleted object's delete undone, and neither
carries anything again: the object came back naming textures that were
gone from the working copy and its manifest. Swept textures are now
parked - out of the manifest, into illusion_parked\ under a name no scan
for *.dds answers to, with their manifest entries kept field for field -
and the next sweep that finds the scene naming one puts it back. What an
earlier run of the program parked is dropped at the first save of the
next; a texture carried again while parked wins over the parked copy. A
file that cannot be moved leaves the texture where it is for the next
save instead of failing the save.

--probe-object-transplant hill shops\harry.sds 50/50 (12 new): sweep with
the objects in / undone / saved twice / redone (same bytes, same manifest
entry, MIP companion with it) / swept again / carried again while parked
/ left by an ended run; the shared block after delete + undo + save +
redo, saved and read back. Without the Reattach change the same probe
dies with the KeyNotFoundException. Live, against the editor: a source
whose collision file is cut short - before the change every later import
under that name answers "already taken"; after it the same refusal twice
and no rows. A scenery import (250 hulls) and an actor import still work,
with undo, redo, undo. Not checked: the failure paths inside ImportPlaced
after the pack took the actor, and a failing GPU mesh.

* test(import): the carried-again check asks for the textures, not for their count

Higher in the stack a texture can also be borrowed from a third archive, so
the second carry brings more than the three objects brought the first time.

* fix(import): the lesser findings of the self-review - carry, links, names, numbers, thumbnails

The second half of the review of this branch. One commit because the
pieces lean on each other; by finding:

Carry. A refused or failed import now takes its carry back: the working
copy's manifest, prefab containers and carried register are noted before
the carry and restored, and the files the manifest gained are removed
(ArchiveCarry.Note / TakeBack). It used to leave textures, item
descriptions and a prefab entry behind for an object that never arrived -
the pack refuses an actor only after the carry. The texture a mesh names
itself (OMTextureHash) is carried, and counted as in use by the save-time
sweep. A MIP companion is copied before the texture that needs it, and a
texture whose companion cannot be brought is not brought. RemoveEntry
writes the manifest through a temp file, as AddEntry did.

Object-to-hull link. The hulls an imported object was given are found by
distance to the object's box, not its pivot - a district mesh's pivot can
be metres from its geometry, and its hulls were then never linked. A move
typed into the Transform panel takes the hulls along (it went through
RecordTransform, which knew nothing of them; past 5 m they were lost for
good and a later delete left them in the .col). Renaming the object moves
its record. The record is written by an edit of the import (and of the
duplicate), so undo takes it back out of the file. Links are worked out
once per operation for the whole selection instead of once per node, each
time re-reading the file and walking every object and placement.

Source. object_import takes `occurrence`: the n-th thing of that name in
the source (actors first, then frames - the ones that draw before helpers
of the same name), and reports how many there are. Position and heading
must be finite. The source archive is kept between imports from it (let
go after a minute, re-read when its working copy changed) instead of
being read whole for every object. "N hull(s) of its own" is now worded
as what it is - the hulls standing inside its box - in the result and in
the tool description.

Prop thumbnails. The archive is read on a pool thread (Stage) and only
drawn on the UI thread (Draw); the tick is guarded, since nothing above a
DispatcherTimer catches; the kept picture is keyed by the working copy's
time stamp as well and replaces the older ones; the held scene is let go
when the tab has all its pictures; texture folders no longer accumulate
across archives (TextureLibrary.ClearFolders).

--probe-object-transplant 53/53 (the take-back on a scratch copy; an
OM-named texture through carry, sweep, park and return).
--probe-object-import-live (new; a real window with `hill` loaded, the
district's working copy compared with its state before and put back):
30/30 - typed move and turn with undo/redo, drag, rename and its undo,
delete and its undo, everything undone leaves no record, occurrence past
the end, a source whose collision is cut short refused twice with the
same answer, a door the pack refuses leaving manifest and folder as they
were. --probe-prop-catalog 9/9, --probe-editor-tools 23/23.
Not checked: the Props tab filling in a window (only Stage/Draw are), a
companion that fails to copy, hulls linked through the box on an object
whose pivot really is far off (the probe's object has it close).
Not changed: "its own hulls" are still chosen by position - an archive
has nothing else to go by; item descriptions and the prefab entry of an
UNDONE import still stay in the working copy.

* fix(cars): the lesser findings of the self-review - target, notices, tuning values, builds that drop entries

The second half of the review of this branch, by finding:

Notices and Blender on the resource editor. Its notices went to its own
banner only, while editor_notices and the Blender tools read the
application's log: blender_push answered "no push arrived" for a push
that had landed, and the line car_tuning_set posts was lost. The window
now writes the log as the map editor does. blender_open counted the MAP's
Blender objects whatever the target and ran out its timeout on a session
opened in the resource editor; it asks the target.

Target. editor_open_area, object_import, actor_import and
editor_mirror_winter make the map the target, as resource_open makes the
resource editor: left where it was, the find/select/delete/save/build
after an editor_open_area went on acting on the car on the stage.
car_tuning_set makes the resource editor the target - its undo entry and
build list are that editor's. EditorStatus says which (Target).

Open editors. car_clone and car_substitute are made from the working copy
on disk: a source open in an editor with unsaved edits is refused. A
substitution whose target is loaded in an editor is refused - its working
copy is replaced under that editor. resource_open of the archive already
on the stage loads nothing (a reload emptied the undo history), and the
two cases the window answers with a dialog - an archive another editor
has loaded, a save that fails - are answered before it is asked: a tool
call that waits for a click never returns.

Re-keying. No root frame of the model's name, no prefab entry, entity
data filed under another name, no buffers: each was a note while the
archive was packed and the car registered. They are failures; the clone
or the substitution is taken back.

Tuning values. A float that is not finite and an integer wider than its
field are refused where the value is written (TuningEditing.Set), so the
Tuning tab is covered too.

Dropped entries. PackSds removes manifest entries whose file is missing
and said nothing. PackResult.Dropped carries them: both editors post them
as an error notice (never silenced), editor_build and the clone list
them, and archive_build - the tool for hand edits - refuses such a build
unless dropMissing is passed, since the entry is gone from the manifest
for good. MissingEntries asks without packing.

Said, not changed: a clone is registered in the main game's tables only -
the three story DLCs ship an ingame.sds of their own, and traffic is
added to CARM* tables only; the result's notes name both. The long file
work of clone/substitute/build still runs on the UI thread (it keeps the
editors from touching the files meanwhile); what was cut is the scratch
extraction MemoryFor repeated on every build of an archive that has no
shipped figures.

--probe-car-clone 75/75 (a car not keyed by the name given is a failure;
a working copy short of a file says which). --probe-car-clone-rollback
11/11, --probe-editor-tools 23/23, --probe-object-import-live 30/30.
Live, on a probe copy of jeep.sds removed afterwards: NaN, Infinity,
1e40 and an integer past its field refused; the tuning notice in the
log; the same car opened twice keeps undo; the target after
editor_open_area and after car_tuning_set; resource_open of the district
the map has loaded answered at once; clone and substitute refused
against the open car; archive_build refused, then built with dropMissing.
Not checked: blender_open/blender_push with the resource editor as the
target (no Blender in the run), the Build dialogs' new line in a window.

* fix(import): the texture index keeps up with the mirror

Found reviewing this branch. The index a third-archive lookup goes by was
one scan, made once: an archive extracted later in the session was not in
it (import a shop gun before weapons.sds was ever opened - 'in no
extracted archive' - open weapons.sds, import again: the same answer
until a restart), and it kept one path per name, so when that path was a
copy the toolkit had carried into a working copy and later took out
again, the lookup answered with a file that was not there and the texture
was reported as held nowhere although its own archive had it.

Every folder holding a name is remembered now (as an index into a list of
folders, not sixty thousand paths), an answer is checked against the disk
and falls through to the next copy when the first is gone, and the
extractor announces the folder it has just made (RegisterFolder).

--probe-object-transplant 60/60 (2 new: a folder announced after the
scan; a name held twice - first copy, second when the first is gone,
nothing when both are). --probe-material-editor 65/65. Not checked: the
shop-gun scenario itself in the editor.

* fix(import): review of #6 - links by place, blocks shared only when alike, the Props tab paged

What the review of this branch found that the commits before this one had
not already answered (the failed-import rollback, the swept textures and
the numeric-edit path are in 56f5f8d and 45236da).

A collision link is a placement, found by its place. The record kept one
hash per hull: two placements of the same hull collapsed into one entry
(Distinct), a resize re-cooked the hull under a new hash the record did
not learn, and a placement was looked for within 5 m of the object's
pivot, which says nothing about where a placement's origin is. Each entry
is now one placement - the hull and where it stood in the object's own
space when the link was made - and it is found where the object's matrix
now puts that point, which every move, turn and resize the two make
together leaves unchanged. The same hull twice is two entries; a resize
rewrites the hashes in the record as an edit inside the same undo step;
a record written before this (bare hashes) is read as before and matched
by nearness to the object's box.

A geometry block is shared only when it draws the same way. Matching
buffers and decompression values was taken as "the same block", so a
second import inherited whatever had been done to the first copy's block
since - a draw distance set to 0, and an object that does not draw. The
draw distance, vertex layout and count and both capsules are compared
(FrameLOD.DrawsLike); the buffers are still shared, the block is copied
when it differs.

The Props tab makes cards a page at a time. A wrapping panel does not
virtualize, and the whole catalog - 2,900 objects here - was given cards
at once, about two seconds of the UI thread on opening and on every
filter change. 120 cards, and the next 120 when the end of them comes
within reach; pictures go to the cards in view first. The picture timer,
stopped when the tab is hidden, starts again when it is shown - a catalog
already loaded had nothing to restart it.

--probe-object-import-live 36/36 (6 new: a resize gives the hull a new
hash and the record follows, undo puts both back; a second placement of
the same hull nine metres from the pivot is the object's too, and both go
with it). --probe-object-transplant 54/54 (a copy whose draw distance was
changed does not lend its block). --probe-prop-catalog 12/12 (the tab in
a 330x700 window: 120 cards for 2928 objects, 240 after scrolling to the
end, pictures resume after hide and show).
Not checked: a record in the old format on a real working copy.

* fix(cars): review of #7 - the scratch clone takes itself back, manifests that do not read, undo re-enlists the archive

What the review of this branch found that c4b33d4 and 64b4e9b had not
already answered (the rollback of Clone and its packs, the stale catalog
behind resource_open, the modal dialog on the tool path and the
non-finite tuning numbers are in those).

CloneExtracted is whole or nothing by itself. The rollback lived in Clone;
the clone on working copies, called on its own, still wrote the vehicle
row before the title, the paint row and the traffic slots, and a failure
at any of them left a car registered by half and its name taken. It now
keeps the journal itself (Clone passes its own in, to add the packs to
it). The journal no longer tries to put back a file that was noted and
never written - with the text table held open, that was the one thing
the undo reported it could not restore.

The M2O export does not write over a manifest it cannot read. An existing
vehicles.json or package.json that does not parse was taken for 'none
yet' and replaced with this one car - after the archives had been copied.
Both are read before anything is written; one that is there and does not
read is a refusal that leaves the folder as it was.

Undo and redo of a value that lives in the working copy put the archive
back on the build list (WorkingCopyEdit, for tuning and effect edits, in
the panel and in car_tuning_set). A Build takes the archive off the list;
an undo after it changed the working copy and the next Build packed
nothing, leaving the game with the value that had been undone.

--probe-car-clone 77/77 (a clone whose text table is held open is refused
and leaves the vehicle table, the ingame tables, the text and the folders
as they were; the same clone then succeeds). --probe-car-m2o 24/24 (a
vehicles.json, then a package.json, that does not read: refused, both
files and the folder untouched). --probe-car-clone-rollback 11/11.
Live, on a probe copy of jeep.sds: set Mass, build, undo - the archive is
pending again; redo - still pending.

* fix(import): review of #9 - the carry tries every copy the index knows

The two findings of the review of this branch. The first - an archive
extracted after the index was built is not in it - is answered by 91722f3
(the extractor announces the folder; the index checks the disk). What was
left of the second: the lookup asked the index for ONE path and took a
copy that could not lend - one in the destination itself, one its own
archive does not list - as proof that no archive holds the texture.
HolderOf now goes through every copy on disk (TextureSearchIndex.FindAll)
and takes the first that is in another working copy and on that archive's
list; a working copy whose manifest does not read lends nothing and is
passed over.

And the default lookup is covered by the probe, not only an injected path
(asked for in the review): --probe-object-transplant 64/64 - a donor
announced after the index was built is found by the carry's own lookup; a
first copy that is not on its archive's list does not end the search; a
donor that has gone and a copy that is the destination's own are passed
over for the next donor.

* fix(props): the picture cache of an earlier build is left alone

45236da gave a prop's kept picture a name that carries the state of its
archive, and on first use deleted every picture under the old name as
something nothing reads. A build from before that change, in use beside
this one on the same folder, reads exactly those: its Props tab lost all
2,928 pictures the first time a probe of this branch ran. Only a prop's
own pictures from earlier states of its archive are dropped now.

--probe-prop-catalog 12/12; 2,928 old-format pictures still there after it.

* fix(bridge): what a second look at 838c69a found

838c69a made the texture files of a push part of its undo and put a
refused material's files back. Read again before it went up:

Undo removed a texture the push had introduced. The material that names
it is created by an edit of its own and outlives the push's entry, and
Blender - told the push arrived - sends pixels again only when the image
changes: after one Ctrl+Z the texture was in no archive, and the next
push put the material on the object with nothing to draw it from. The
undo entry takes back pictures that were REPLACED; an introduced one
stays with its material.

Undo and redo did not put the archives back on the build list. Push,
Build, Ctrl+Z: the folder had the old picture, the game's archive the new
one, and Build answered that there was nothing to build.

A push that only repainted a texture was dropped from the history by the
first unload of any district: its entry names no objects, and 'all of its
objects have left the scene' is true of none.

A write that failed was remembered as done. The name was noted before the
write; the …
@Segfaultd
Segfaultd merged commit 4e900ef into mafia2online:main Oct 7, 2026
1 check passed
Segfaultd pushed a commit that referenced this pull request Oct 7, 2026
…irror for districts (#5)

* feat: preview a material on the context mesh slot in the Material Editor

Local work found uncommitted in the working copy (base a55dc22, v0.3.1):
sphere/mesh preview toggle, the slot label on the assign panel, and the
SDK pin moved to 10.0.302.

* feat: turn a material made in Blender into a game material on push

A push used to refuse any slot whose material had not come from the
toolkit. A Blender-made material now travels with the image wired into
its Principled Base Color and becomes a real game material:

- addon: sends the image as RGBA8 (top-down, resampled to powers of two)
  once per push however many slots use it; the ack stamps the hash back
  on the datablock so later pushes send pixels only when they changed
- DdsEncoder: DXT1 with a full MIP chain and the stock header, written
  as a single Texture entry (HasMIP 0) - the shape 2809 stock city
  textures ship in, so no companion Mipmap entry is needed
- AuthoredMaterialResolver: fills in the hash before the mesh path runs;
  binds by name when the game already has the material, otherwise writes
  the texture into the object's archive (file + manifest) and creates a
  default-preset material with it in S000 (one undo entry)
- a re-push with new pixels rewrites the texture in place and the
  renderer reloads it (TextureLibrary.Invalidate)

Probe: --probe-bridge-material [district] [push.ilx] - encoder, resolver,
re-push, bind-by-name, refusal, and optionally a container written by
the addon itself.

* fix: stop the Material Editor throwing while its XAML loads

The Sphere toggle is checked in markup, so its Checked handler ran before
the Mesh toggle and the preview existed and the window died with a
NullReferenceException on open. The handler now waits for both.

The probe read the assign BUTTON's visibility; the button is hidden
through its panel since the mesh-preview change, so it reads the panel.
--probe-material-editor: 65 passed, 0 failed (was 50 + an exception).

* fix: live-test the Blender material push and close what it found

A live probe drives the real viewport and a real bridge session
(--probe-bridge-material-live): an off-screen D3DImageHost loads an
archive, opens a mesh in a running bridge Blender and waits for an
object made there, wearing a material made there, to be pushed back.
It checks the scene, the renderer, a re-push with a changed image,
undo/redo, Save, a reload from disk and a pack, then restores every
file. 21 passed against Blender 5.1.2.

What it found:
- image resize went through Image.copy().scale(), which hands back the
  blank original of a painted or generated image - the texture arrived
  black. The addon now resamples the pixel array itself.
- a rebuilt mesh's parts carried no material hash, so a later material
  edit (or a texture rewritten by a push) could not find them to
  re-resolve. The hash and tint now ride with the part, as on a mesh
  loaded from disk.
- Blender keeps the hash of a material the library may have lost
  (creation undone, toolkit closed without Save). With pixels it is
  made again; without them the push is refused once and the ack tells
  the datablock to forget, so the next push arrives complete.

* fix: store large textures the way the game does, and stop Build losing resources

A Blender-made material came out BLACK in game. The object was there and
the material was sound (same shader, flags and sampler states as 1888
stock materials) - the texture was not in a shape the game loads.

- From 256x256 up the game stores a texture split: the top level alone
  in a Mipmap entry, everything from half resolution down in the Texture
  entry with HasMIP=1 (6747 split textures surveyed, no exception; no
  stock mip-chained texture is wider than 128 on its short side). The
  encoder wrote a 1024x512 texture whole. DdsEncoder.Encode now returns
  the pair, and ArchiveTextureWriter writes both files and both entries.

Two packer bugs found while comparing the rebuilt archive with stock:

- TextureHandler charged a split texture only its own payload. Stock
  charges the Texture entry its payload PLUS its MIP companion's and
  leaves the Mipmap entry at zero (381 of 381 in italy.sds), so every
  Build under-reported the archive's video memory by the size of all its
  top levels - 6 MB for italy.
- PruneMissingEntries joined a rooted resource name ("/missions/...")
  with Path.Combine, looked for the file at the drive root, and unsaid a
  resource that was present. italy.sds lost its AudioSectors on Build.

--probe-bridge-material: 30 passed (split layout, the memory figure
against the files on disk, pruning with a rooted name).

* fix: give a created material the two fields the stock ones carry

The billboard was still black in game after its texture was stored
correctly. The material had been compared with stock on shader, flags,
sampler states and parameters - through a view that does not show every
field. Two were left at zero by the Default preset:

- Unk0: 128 on 1837 of the 1929 stock materials on that shader
- the diffuse sampler's TexType: 2 on 1947 of 1951 (0 on four)

Material_v57's Default preset now sets both, which also covers the
materials the glTF import creates. v58 (Definitive Edition) is left
alone: there is no DE install here to measure it against.

The probe compares a created material with the majority of stock
materials on its shader in these fields, so a preset that drifts from
the game's own records fails here instead of in game.

* fix: rename a probe local that clashed with one further down

The previous commit did not build: the new stock-record check declared
'made', which the addon-container section of the same method already
uses. --probe-bridge-material: 25 passed.

* feat: carry a Blender material's normal and specular maps into the game

A Blender-made material arrived with its Base Color image only. It now
also brings the image behind its Normal Map node and the one driving
its specular level, and becomes a normal-mapped game material.

Chosen from a survey of the stock library, not assumed:
- shader 5159568776351604322 (S000 + S001, parameter D013): 1209 stock
  materials, drawn on the P|N|T|UV0 declaration a pushed mesh already
  has. Its S001 is one combined texture - normal X and Y in red and
  green, the SPECULAR level in blue (stock "...NS" textures: red/green
  127 +- 10, blue 30..240), DXT1 like 97 of the 120 sampled.
- NormalSpecularPacker builds that texture from the two Blender images
  (either may be missing) and inverts green: Blender's normal maps have
  green up, the game's bitangent follows texture V, which runs down.
- MaterialPreset.DiffuseNormal creates the record in the commonest
  stock shape of that shader - Unk0, flags, both samplers' TexType and
  states, D013 - and the probe compares it field by field with the
  majority of the 1209.
- Blender's roughness and specular level become D013's power and level;
  the defaults (0.5 / 0.5) land on the commonest stock pair, 16 / 0.3.

A material that gains or loses its normal map needs another shader, so
it is replaced under the same name - the hash meshes refer to does not
move. The resolver now talks to an IAuthoredMaterialHost (create,
update, replace) instead of two callbacks; the application's host
records each on the undo history.

Also: binding a texture to a sampler sets its TexType to 2 (a slot
added in the Material Editor started at 0, which samples nothing in
game); two materials sharing an image share one texture file.

--probe-bridge-material: 33 passed. Driven live through the real
viewport against a windowless Blender: the billboard in italy took a
new material with all three maps and shows the relief in Render mode.

* style: dotnet format the preview viewport's mesh builder

* feat(mcp): tools that drive the running map editor

The MCP server could read game files but not touch the editor, so a client
working on a scene had to fall back on UI automation for everything the
window does. These tools close that gap:

- editor_status / editor_list_areas / editor_open_area (opens the editor
  from the launcher and waits for the area to finish loading)
- scene_find (by name, kind, or a world box tested against a mesh's
  triangles rather than its bounds) and scene_select
- blender_open / blender_end, and editor_notices for what a push reported
- editor_save / editor_build, object_move, scene_delete_selected,
  editor_undo / editor_redo
- camera_get / camera_look_at / camera_set / camera_frame_selection,
  view_set (shading mode and layers) and viewport_screenshot

Illusion.Mcp gets the IEditorSession seam; the application implements it
over the open MainWindow. Supporting changes: ViewportControl.CaptureFrame,
LookAt and LookFrom, DistrictStreamer.IsBusy, LauncherWindow.OpenMapEditor,
and a notice log the banner feeds.

* feat(mcp): blender_push, and scene_select with no names clears the selection

blender_push sends the bridge's request_push, waits for the push to land
and returns what the editor reported, so a client that edited the session's
objects in Blender gets the outcome in the same call instead of polling the
notice log. An empty scene_select clears the selection: its outline is
drawn through walls and would otherwise sit in every screenshot.

* fix(bridge): refuse a pushed mesh over 65535 vertices instead of writing 32-bit indices

A rebuild that came to more than 65535 split vertices switched the level's
index buffer to 32-bit. The toolkit's viewport draws that; the game does
not — an interior of 65 981 vertices came up as triangles smeared across
the district, textures flickering, while the editor showed it whole.

The push is now refused with the vertex count, so the modeller can split
the object. A rebuild always writes 16-bit indices, and a level that still
carries a wide buffer from before is rebuilt on its next push whatever
that push changed, which is what puts it right.

scene_find reports each mesh's vertex and triangle count, so the size of
an object can be checked before it is built into an archive.

* feat: bring a light into a district that has none — actor import across archives

A city district ships with no lights of its own: its interiors are lit by
LightEntity actors, and the only way the editor could make an actor was to
copy one already in the pack. ActorsFile.Import copies an actor out of
another archive's pack, with its own copy of the behaviour row it points
at; the editor wraps it as one undoable edit and gives it a row in the
tree, creating the entity type's section when the district never had one.
Only an actor that places no object of its own scene can travel — a light,
a sound — and the copy is linked to a frame named after itself, which is
the frame such an entity makes.

A light keeps its transform twice: in the actor record and at the head of
its behaviour blob (every shipped light's x appears exactly twice). The
record is what the editor moves, so the pack now carries the translation
over into the blob when it is written; an untouched pack still writes byte
for byte.

MCP: actor_import, scene_duplicate_selected, object_properties and
object_set_property (the property panel as data, undoable writes), and
decode_actors can list each actor's behaviour fields.

* Keep a light's inverse matrix and clip box in step with where it stands

A light's behaviour blob ends with the inverse of its world matrix, and the
game works the light out through it. A light imported from another archive
(or moved in the editor) kept the old inverse: every field read correctly,
the light stood in the right place, and it lit nothing.

SyncLightFrame now rewrites the inverse whenever it is out of step with the
head matrix, carries the clip box along by the distance the light moved, and
runs on import as well as on write. Untouched packs still write byte for
byte: shipped lights are within 1e-4 of a true inverse and are left alone.

* Show and edit a mesh's draw distance in the property panel

Each level of detail carries the distance at which it stops being drawn,
stored squared. It is the one cost control a static object has - the game
culls by distance and view cone only - and an object made through the
Blender bridge starts at a million metres. The panel (and so the MCP
object_set_property) now lists one draw distance per level, in metres.

* fix: touch a light's row only when the light has moved

The light sync rewrote any inverse matrix that was out of step with the
head matrix. The install has 72 such rows that shipped that way (three
Joe's Adventures archives carry zeroes or noise there), so re-saving those
untouched packs changed them: --probe-act-relayout, --probe-actor-props and
--probe-native-misc fell to 1047/1050.

Now a light whose record and head translation agree is left alone, and a
moved light gets a new inverse only if the one it had was a true inverse.
The clip box and inverse are also written to the row's named fields: on
write the core pokes every named field back over the blob, which put the
imported light's old box straight back.

--probe-act-relayout gains the import: a light from one archive into a
pack of another, checking its own row, matrix, inverse, clip box, the
writer round trip and a refused duplicate name. 1050/1050 packs re-save
byte for byte again.

* docs: the editor tools, the Blender material push and their limits

README: the MCP section lists the 25 editor tools and says how they relate
to the editor's own undo, save and build; the bridge section covers pushed
materials and the 65535-vertex refusal; the property panel mentions draw
distances; Known limits gains opaque-only pushed materials and undrawn light
actors. --probe-mcp now expects the editor tools as well.

* feat: carry a Blender material's alpha into the game - cut-out and translucent

A material made in Blender was always opaque: its diffuse went to DXT1 and
its flags were the plain ones. Now whatever feeds the Principled BSDF's
Alpha - the image's own alpha, a mask image, or a value below 1 - is written
into the diffuse texture, which is then stored as DXT5, and the material's
render method picks the mode: Blended is a translucent surface, anything
else a cut-out.

The game has no separate shader for either on the shaders the bridge
creates materials for; the flag word decides. Measured over default.mtl on
the diffuse shader: 239 materials set Alpha and keep writing depth (fences,
signs, foliage), 323 set Disable_ZWriting on DXT5/DXT3 textures (overlays),
11 carry both. A cut-out gets Alpha and keeps CastShadows; a blend gets
Disable_ZWriting and drops CastShadows; every other bit is left alone.

- DdsEncoder: DXT5 (interpolated alpha block + the DXT1 colour block), with
  the header stock DXT5 files carry.
- MafiaMaterialCatalog: GetFlags / SetFlags; the editor's SetFlags is an
  undoable edit.
- The addon sends alphaMode with the material and puts the alpha it means
  into the image; an opaque material keeps its old signature, so nothing is
  resent for nothing.
- --probe-bridge-material: 40/0, with the encoder's alpha round trip, the
  flag words, and one material taken through cut-out, blend and back.

* feat(viewport): draw alpha-blended materials blended

The mesh shader alpha-tested every textured material at one half. That is
right for a cut-out and wrong for glass: a pane whose alpha is 0.3 was
clipped away entirely, so a translucent material - a stock one or one
pushed from Blender - was simply not there in the viewport.

A part now knows whether its material blends (Disable_ZWriting in the flag
word), and such parts are drawn in a pass of their own after the opaque
one: depth-tested, never depth-written, standard alpha blend, no clip. The
flag follows a material edit the same way the textures and the tint do.
Unsorted on purpose - a pane seen through a pane may composite in the wrong
order. Instanced props keep the alpha test.

* feat: mirror a district's edits into its winter archive

A district ships twice and an edit made to <name>.sds is simply absent
from <name>_z.sds. For most districts the two are one scene: measured on
uppertown, every shared resource (buffer pools, name table, actors,
collisions, item descriptions, prefab) is byte for byte the same, and the
frame resource has the same length and differs in 1740 places - each a
material hash swapped for its ^zima counterpart, 82 pairs. Textures aside,
nothing else differs.

SeasonMirror writes the summer working copy over the winter one with each
object's winter materials taken from the winter object of the same name
(and occurrence - names repeat), copies the shared files that differ, adds
the pools summer gained with their manifest entries, and adds the textures
the result names that winter lacks. The caller queues the winter archive
for Build.

Not every district is such a pair: 13 of 23 are, ten have a winter scene of
their own. The pair is therefore judged first on the archives as shipped
(each one's oldest backup, or the archive itself if never built), and a
district that fails is refused rather than overwritten.

- MCP: editor_mirror_winter.
- --probe-season-mirror: 11/0 on scratch folders built from one working
  copy, so it does not depend on what the modder did to the install - an
  untouched pair is left alone, a missing object arrives and winter is
  byte for byte whole again, a stranger is refused untouched; reports which
  of the install's pairs are twins.

* perf(bridge): read a level's index width without decoding the mesh

Review of #3. HasWideIndices decoded the whole level - every vertex
channel, the index array and, on a model, the skin - to read one field of
its index buffer, on every push, ahead of a path that decodes the mesh
again. It now clamps the level and looks at the index buffer directly.

--probe-bridge-rebuild 17/17, --probe-bridge-vertex 2/2,
--probe-bridge-resplit 6/6.

* fix(mcp): editor tools say no when the editor does, and find crash copies from the table

Review of #3, the findings in the MCP session.

editor_open_area dropped unsaved edits. Changing the area or the season
reloads the scene, which clears the edited frames, the unsaved list and
the undo history, and the tool only checked for a Blender session. A load
that would replace a scene with unsaved edits is now refused unless the
caller passes discardUnsavedEdits=true; asking for the area and season
already shown stays a no-op.

editor_save and editor_build reported success after a save that did not
complete. The viewport's save does not throw for a material library that
would not write or a car's working copy that was refused - it posts a
notice and carries on - so the tool had nothing to return but success, and
a build went on to pack archives whose materials were not on disk.
D3DImageHost.SaveEditsReport hands back what was left unsaved;
editor_save answers success=false with notSaved, and a build whose save
did not complete packs nothing and says why.

object_set_property could not rename. Base.Name is offered as an editable
HashName and the parser had no case for it. It has one now, with the
property panel's rule (an empty name is refused), and a rename to the name
the object already has is not committed as an edit.

Numeric properties took NaN and infinity. float.TryParse accepts "NaN",
"Infinity" and "1e100" (as infinity); a float and each component of a
vector now have to be finite.

scene_find did not see crash copies until their row had been expanded in
the tree, and a box query never returned one. They are now searched from
the placement table: by the copy's own label or its prop's name, and by
the prop's triangles at the copy's matrix. Only the copies returned get a
tree node.

--probe-editor-tools (new, no game install needed) drives the real tool
methods against a scripted editor and the parser directly: 23 checks.
Checked live in the editor on uppertown: 5 lamp copies found with no row
expanded, 288 triangles of one in a box round it, none 400 m above it; a
moved mesh blocks a load of another area and of the other season, the
same area is a no-op, discardUnsavedEdits=true loads. A failing material
write was not reproduced live - that path is covered by the scripted
editor only.

* fix(mirror): the winter mirror decides against the pair as shipped, not against the last mirror

Review of #5, the four findings in SeasonMirror.

Twins were told by size. Two archives passed as "one scene shipped twice"
when their shared pools were equal and their scenes the same length - and
a winter scene with one object moved 50 units is the same length. The
scenes are now parsed, winter's material hashes replaced by summer's, and
the two written back out: anything left that differs (a transform, a
parent link, a draw distance) means winter is a scene of its own and the
mirror is refused. The 13 stock twins still read as twins and the other
ten as not.

Everything else follows from reading that pair once (SeasonPair) and
deciding against it, where the mirror used to ask the winter WORKING COPY
what winter wore - which answers with whatever the previous mirror left:

- A slot the modder re-pointed in summer got its old winter material
  back. A summer material no object of that name wore when the pair
  shipped is now carried into winter as it is (Report.Reassigned).

- Namesakes were matched by position among objects of the same name, so
  deleting the first handed its materials to the second. The season's
  substitution is now looked up by name and SUMMER MATERIAL; position is
  only used when one summer material stands for two winter ones under one
  name, and only while the number of namesakes is what it shipped as.
  Otherwise the object keeps summer's material and is counted
  (Report.Ambiguous, raised as an error notice) rather than dressed in
  another object's snow.

- A texture an earlier mirror had brought was never refreshed: the bridge
  repaints an authored texture in place, and "winter has a file of that
  name" ended the matter. A file winter did not SHIP with is now compared
  and rewritten, its MIP_ companion with it (and removed when the new
  picture has none); a file winter shipped with is the season's own and
  is left alone.

Mirror() also refuses a winter folder that is not made of the pair's
meshes before writing anything, and editor_mirror_winter stops when the
save it starts with did not complete. Its result names slotsReassigned
and objectsAmbiguous in place of objectsReshaped.

--probe-season-mirror builds its own namesakes and runs every case above
on scratch copies: 23/23 on uppertown and oysterbay, 22/22 on italy,
21/21 on greenfield (not a twin, so the shipped-texture checks do not
apply).

* fix(bridge): a material push that can be undone, refused cleanly and shared across archives

Review of #4, all ten findings.

Texture files are part of the push's undo. A repaint under the same name
changes no binding, so the catalog recorded nothing and Ctrl+Z could not
bring the old picture back. The resolver now journals every file it
writes (what the name held before, what it holds now) and the push
carries a TextureFilesEdit in its one history entry: undo restores the
old files - or removes a texture the push introduced, manifest entries
included - and redo puts the new ones back.

Nothing is written before everything is checked. The diffuse texture was
on disk before the normal map had been looked at, and before the library
had accepted the material. All images are now read, validated and packed
first; what is written before a later file or the library refuses is
rolled back from the same journal.

A split texture is compared as two files. PickName compared only the
entry, which for a texture of 256 and up starts at half resolution: a
fine checkerboard and the grey it averages to are byte-identical there
(reproduced), so the second picture took the first one's name and its
write replaced the first one's top level. The MIP_ companion - and
whether there is one - is now part of the comparison.

A material shared by two archives is packed into both. The second object
of a push binds the new material by name, and an unchanged material
arrives with no pixels; either way an archive that had never held its
textures got none. The files are now copied from the archive that has
them (only for materials this bridge created - a stock material bound by
name copies nothing).

A renamed material keeps its hash across a shader change. Gaining or
losing a normal map deletes and recreates the record, and it was
recreated under the Blender datablock's current name - a new hash, with
every other mesh left pointing at the old one. It is rebuilt under the
name the library knows it by.

Reloads are coalesced. A push of a hundred objects sharing a material
asked for a hundred invalidate-and-rebind passes over every loaded mesh;
each texture is now invalidated once and each material that names one
rebinds once (ReloadTextureFiles).

A rebound part returns the textures it held. GpuMesh kept every lease
until dispose, so the library's retired copy of each repainted texture
stayed on the GPU for as long as the mesh was loaded. Leases are kept per
part and the superseded ones released once the part holds the new ones.

The mesh preview keeps the material's tint.

Add-on: a Normal Map node in Object or World space is no longer exported
as if it were tangent space - the material is refused by name with what
to change - and the resampler clamps its two neighbours independently:
upsampling [0, 1, 0] to four texels gave 0.875 at the left edge, now 0.

--probe-bridge-material 58/58 on eastside (30 new checks: each case
above, including twenty repaints of a texture a live GpuMesh draws
leaving nothing retired), --probe-material-editor 65/65,
--probe-bridge-rebuild 17/17, --probe-mcp 66/66. The undo entry itself
was checked at the file level (restore before/after), not by pressing
Ctrl+Z in the editor, and the two add-on changes were not run inside
Blender: the resampler was checked on the reviewer's numbers outside it.

* fix: the material preview blends what the scene blends; an alpha mask is sampled linear

Review of #5, the two findings outside the mirror.

The material editor's preview still alpha-tested a translucent material.
SphereMesh forwards a part's Blended flag, but the part the preview
builds for a material carried only its tint, and the mesh-shape preview
dropped the flag again - so a uniform 0.3-alpha pane vanished from the
ball while the scene drew it blended. The flag is read from the catalog
with the tint and kept in both preview shapes.

Add-on: a colour image used as an alpha mask was read through
image_rgba8, which turns a picture into what an albedo texture stores
(sRGB bytes), and the luminance of those encoded values was written as
coverage. A float mask at linear 0.25 came out as 137/255 instead of 64 -
enough to carry a cut-out across the 0.5 it is tested against. The mask
is now read in the values the shader works with (_shader_pixels): float
buffers as they are, 8-bit sRGB linearized as the Image Texture node
does, Non-Color data and the alpha channel as stored.

Run in a background Blender on the add-on as it stands in this branch:
linear 0.25 float mask -> 64, 8-bit sRGB 0.5 -> 55, Non-Color 0.5 -> 128,
alpha channel 0.3 -> 77; the same run covers the two add-on fixes merged
from #4 (Normal Map node space, resampler edge). The preview change was
not looked at in the material editor window.

* fix(bridge): what a second look at 838c69a found

838c69a made the texture files of a push part of its undo and put a
refused material's files back. Read again before it went up:

Undo removed a texture the push had introduced. The material that names
it is created by an edit of its own and outlives the push's entry, and
Blender - told the push arrived - sends pixels again only when the image
changes: after one Ctrl+Z the texture was in no archive, and the next
push put the material on the object with nothing to draw it from. The
undo entry takes back pictures that were REPLACED; an introduced one
stays with its material.

Undo and redo did not put the archives back on the build list. Push,
Build, Ctrl+Z: the folder had the old picture, the game's archive the new
one, and Build answered that there was nothing to build.

A push that only repainted a texture was dropped from the history by the
first unload of any district: its entry names no objects, and 'all of its
objects have left the scene' is true of none.

A write that failed was remembered as done. The name was noted before the
write; the next object of the push sharing the image was handed it as
success and its material named a file that was never made. Noted after.

A texture half written - the entry replaced and its top level, or the
manifest, not - was caught with nothing on record to put it back from. It
is put back before the failure is passed on, and neither that nor
AtomicFile leaves its .tmp beside the target any more.

An object is refused before any of its materials is touched: every slot
is checked first for what can be refused without writing (normal map
space, images that did not arrive whole). A bad second slot used to be
found after the first slot's pictures had been replaced. A texture that
cannot be copied into the object's archive refuses the object as well.

--probe-bridge-material 61/61 (3 new: the two-slot object; a texture held
open refuses both objects sharing the image and leaves the picture, the
manifest and the folder as they were). --probe-material-editor 65/65.
The undo entry itself (introduced texture kept, archives re-enlisted, the
entry surviving an unload) is not covered by a probe - it lives behind
the editor's history.

* fix(mcp): a layer is not switched off over unsaved edits; moves and imports take finite numbers

A second look at f12155b. editor_open_area was given a guard against
dropping unsaved edits, and view_set was left without one: switching the
crash (or collision) layer off unloads it, and an unsaved move of a crash
copy went with it, undo entry included - the same loss by a shorter road,
and an easier one to take now that scene_find hands out crash copies.
Refused while the scene has unsaved edits, unless discardUnsavedEdits is
passed; switching a layer on, or off with nothing unsaved, is as before.

object_move and actor_import took a position or an offset as it came: a
number too large for a float arrives as Infinity and was written into the
transform. They refuse what is not finite, as property values already do.

--probe-editor-tools-live (new; a real window with a district loaded,
nothing saved) 12/12: both refusals of non-finite numbers; the layer off
and on with nothing edited; a crash copy moved, the layer refused, zones
still switch on, the layer off once the edits are given up.
--probe-editor-tools 23/23, --probe-mcp 66/66.

* fix(mirror): a second look at 3696630 and a3b5f73

Read again before they went up.

A material block worn by two objects was settled twice. The mirror writes
winter's hashes into the block, so the second wearer was resolved from
them as if they were summer's - counted as a slot the modder had
re-pointed, or substituted again where a winter hash is also a summer
one. The wearers of each block are gathered first and the block is
settled once, from summer's hashes, by whichever wearer the pair has an
answer for.

A mesh that existed only in winter was lost without a word. 'Dropped' had
become the shipped pair's meshes summer no longer has; it is what the
mirror takes out of winter - every winter mesh the mirrored scene has no
counterpart for.

A refreshed texture kept its old manifest entry. A repaint at another
size changes whether the texture has a MIP companion, which its entry
states (HasMIP); the bytes were brought up to date under an entry that
promised a companion no longer there, or hid one that now was. The entry
is compared with summer's and replaced with it.

The material list's tiles draw a blended material blended, as the preview
beside them does since a3b5f73. And the add-on's push signature includes
the colour space of an alpha mask: an 8-bit mask is linearised or not by
its tag, so retagging it changes what is sent while name, file and size
stay the same - and nothing was resent.

--probe-season-mirror uppertown 28/28 (5 new: a mesh only in winter is
reported as dropped; a block two objects wear, the wearer that never wore
the summer material first in the file; a refreshed texture's entry
follows a changed HasMIP). --probe-bridge-material 68/68,
--probe-material-editor 65/65. The add-on change is compiled, not run in
Blender.

* fix(bridge): the pre-pass refuses a material it cannot make; no empty undo entry

Two things 4034b0f left. The check of every slot before any is written did
not include a material that would have to be made and arrives with no
picture to make it from - new, or one Blender remembers and the library
no longer has: found at its own slot, after an earlier slot's texture had
been replaced. Both are refused in the pre-pass now, in the words they
had. And a push whose only texture changes were introductions made an
undo entry with nothing in it - a Ctrl+Z that did nothing and cost the
redo branch.

--probe-bridge-material 62/62 (1 new: a repaint first, a new material
with no image second - refused, the first left as it was).

* fix(mcp): the layer guard is about the crash layer only

01973ba guarded switching the collision layer off as well, on the belief
that it unloads the layer. It only hides it: the placements, their edits
and their undo entries stay, so the guard refused for nothing and its
discardUnsavedEdits discarded nothing. Crash only, which does unload. And
object_move refuses a position plus offset that overflows, though each
number alone is finite.

--probe-editor-tools-live 14/14 (collision on and off with an edit
unsaved; the overflowing move).

* fix(mirror): wearers that disagree, names that differ in case, an entry that cannot be copied

Three things 7750e77 left. Wearers of one material block that give
different winter materials for a slot: the first in file order won and
both were counted as settled; the slot keeps summer's and the objects are
put down as in doubt. A texture entry compared with summer's by exact
spelling of the file name: a material that spells it otherwise than the
manifest made every mirror rewrite the entry and report a refresh. And
the old entry was removed before knowing summer's could be copied in its
place.

--probe-season-mirror 28/28.

* fix(mirror): the same bytes are not written again for an entry that cannot be copied

b2f3b79 left one case: a texture whose bytes already match summer's but
whose manifest entry differs from a summer entry that cannot be copied
fell through to the write, so every mirror rewrote the same file and
reported it as refreshed. Nothing is written and nothing is reported.

--probe-season-mirror 28/28. The case itself has no probe: no stock
archive has such an entry.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants