[{"id":"A13","fact":"**The Blender addon's response frame caps at 64 MiB and refuses cleanly above it.** `pack_json_message` raises *before* `sendall`, so no partial frame can reach the wire, and `server.py` distinguishes too-big from non-serialisable on the substring `frame too large`. Per-object response costs: **`list_objects` 161.6 B, `list_objects{detail:true}` 491.6 B, `scene_info` 60.6 B** — so the cap lands near **415 000 / 136 500 / 1 100 000** objects respectively. The shipped `DEFAULT_JOB_TIMEOUT` is **150.0 s** (`server.py:76`).","source":"`framing.py:30` `MAX_MESSAGE_BYTES = 64 * 1024 * 1024`, raise at `:35-38`, `send_json_message` at `:42-43`; `server.py:663-690` for the two-way discrimination. Live, Blender 4.4.0 headless 2026-09-09: a payload 1 KiB over the cap produced `frame too large: 67109914 bytes (cap 67108864)` and satisfied server.py's own `\"frame too large\" in str(exc)` test. Byte costs measured at 1000/5000/20000/50000 objects (159.9 → 161.6 B/obj for `list_objects`, stable). NOTE the addon's own comment cites **169 B/obj measured on 5.0.1**; 161.6 is 4.4.0 — same conclusion, different build. Probe: scratchpad `unbounded_probe.py`","engine":"","ue":[],"bl":[],"section":"MifBridge API contracts"},{"id":"A10","fact":"**Blender's XYZ euler composes as `Rz @ Ry @ Rx`**, and `Matrix.to_euler()` / `matrix_world.to_euler()` called with no argument **always answers in XYZ order whatever the object's `rotation_mode` is** — so reading `matrix_world.to_euler()` off a scene somebody else authored is safe, and rebuilding the matrix from it needs the ZYX product. This is what `light_readback` emits as `rotationEuler` (`ops_common.py:591`), in radians, world-space.","source":"live, Blender 4.4.0 headless (`--background --factory-startup`), 2026-09-09: for euler (25°, −40°, 115°), `Euler('XYZ').to_matrix()` matched `Rz@Ry@Rx` to 1.19e−07 (float32) and differed from `Rx@Ry@Rz` by **1.27** — not a close call. A lamp authored with `rotation_mode='ZYX'` still read back `to_euler().order == 'XYZ'`, and its world −Z rebuilt from that euler as ZYX to 5.96e−08. Script: scratchpad `euler_order.py`","engine":"","ue":[],"bl":[],"section":"MifBridge API contracts"},{"id":"B1","fact":"**On a mesh with NO colour attributes, the first one created occupies BOTH slots** - `color_attributes.active_color` and the attribute at `render_color_index` - whatever `set_vertex_color`'s `makeActive`/`makeRender` say. With an attribute already present the flags work exactly as documented: `false` leaves both slots on the existing attribute, `true` moves both to the new one. So a one-attribute test cannot tell a working flag from a broken one, which is why `audit_blender_inert_params` read both as inert.","source":"2026-09-10: `op_set_vertex_color` called in-process from the repo addon on each build, state read straight from bpy (active_color.name, render_color_index → name); identical on all five. Re-runnable as S118a in `tools/test_blender_scene.py` - PASS 189 / FAIL 0 on all five builds in the full `run_blender_suites.py` sweep (80 runs, 0 failed, 0 skipped), 2026-09-10.","engine":"3.6.23, 4.2.17 LTS, 4.4.0, 5.0.1, 5.2.1 LTS","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"—"},{"id":"B2","fact":"**A keyframe's `period`, `amplitude` and `back` shape its easing, per key.** A longer ELASTIC `period` (in frames) reaches the target LATER. An `amplitude` above the size of the move raises the peak. A larger `back` raises BACK's overshoot. Blender's defaults (period 0, amplitude 0) make an ELASTIC spring that is at the target almost at once and then buzzes.","source":"The comparisons are re-runnable as A109 in `tools/test_blender_anim.py` (curve sampled through `evaluate_at_frame`; anim suite 68/0), commit a46b83d. The numbers come from a scratch probe of the same move that was not kept.","engine":"4.4.0 (the full sweep has not run it on the other builds yet)","ue":[],"bl":["4.4"],"section":"Blender behaviour","value":"0-to-10 move over 100 frames: 9.5 is reached at frame 2 on the default and at frame 10 with period 45; amplitude 15 lifts the peak from 10.19 to 12.34; back 4 lifts BACK's peak from 11.00 to 13.79 (one measurement, 2026-09-13)"},{"id":"B4","fact":"**`audit_placement` runs NO test it was not asked for: containment needs `bounds`, floating and sunken need `floor`, and the pairwise sweep needs `checkOverlaps` (default false).** Called bare it answers `ok` with `outsideBounds`, `floating`, `sunken` and `overlaps` all empty and all four totals zero, having compared nothing - a clean bill of health over an unaudited scene. `maxReport` caps each LIST while `overlapsTotal` keeps the true count; `evaluated` (default true) measures the modifier result, so an object inside its own bounds can be outside them once its modifiers run.","source":"live, headless Blender 4.4.0, port 8799, 2026-09-14. Re-runnable: `tools/test_blender_placement_audit.py` (14 checks, builds and removes its own fixture). Op source `tools/blender-addon/MifBlender/ops_query.py:1338`","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender behaviour","value":"bare call on a 7-object fixture: audited 7, all totals 0. `bounds` -> 3 outside. `floor` 0.0 -> 1 floating, 1 sunken; `floorTolerance` 3.0 -> both 0. `checkOverlaps` false 0 -> true 3 pairs. `maxReport` 1/2 -> 1/2 listed, total 3 both times. An ARRAY modifier `count` 3 on a 1.0 cube at the origin: `evaluated` true reports worldMax x 2.5 and outside a +-2.0 box, false reports 0 outside."},{"id":"B5","fact":"**`set_camera`'s `lookAt` aims by a closed formula, with `ry` always 0.** With `d = target - camera`: `rx = atan2(hypot(d.x, d.y), -d.z)`, `ry = 0`, `rz = atan2(-d.y, -d.x) + pi/2`. An euler component is an ANGLE and Blender returns whichever representative it likes, so compare modulo 2*pi - the second sample below comes back as -2.034444 where the formula gives 4.248741, the same direction.","source":"live, headless Blender 4.4.0, port 8799, 2026-09-14. Re-runnable: `tools/test_blender_camera_params.py` row B926, which checks the FORMULA on two independent aim points rather than one recorded pair. Op source `tools/blender-addon/MifBlender/ops_lightcam.py:1044`","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender behaviour","value":"camera (1,2,3) aimed at (0,0,0) -> `[0.640522, -0.0, 2.677945]`; camera (-4,1.5,2) aimed at (1,-1,0.5) -> `[1.308643, -0.0, -2.034444]`. Both match the formula to 1e-3."},{"id":"B7","fact":"**`objects_overlap` reported intersection for objects that cannot touch, whenever one was rotated more than 90 degrees - FIXED 2026-09-14.** `_world_bvh` transformed the bmesh into world space with `bm.transform(matrix_world)` and never called `bm.normal_update()`, so `_is_inside`'s signed-distance test `(point - location).dot(normal) < -tol` compared a world-space displacement against a PRE-ROTATION normal. Below 90 degrees the stale normal still has a positive dot with the true one and the sign survives; past 90 degrees it flips and the test inverts. The 90-degree boundary is the signature.","source":"live, headless Blender 4.4.0, 2026-09-14. A unit cube at the origin and a unit cube at (9,9,9) - centres 15.6 apart, half-diagonal 0.87, contact impossible at any rotation. Sweeping A's X rotation in 15-degree steps BEFORE the fix: DISJOINT from 0 to 90, then `B_INSIDE_A` with bInA 14 of 14 at 105, 120, 150, 165 and 180, and `INTERSECTING` with 11 at 135 - while `aabbSaysOverlap` stayed false throughout, so the same response held a correct reader and a wrong one. AFTER: DISJOINT at all thirteen, and a genuinely overlapping pair still INTERSECTING with the subject rotated 125 degrees. Fix: one `bm.normal_update()` at `tools/blender-addon/MifBlender/ops_query.py` after the world transform. Re-runnable: `tools/test_blender_overlap_geometry.py` rows B1004 and B1013, both red against the unfixed addon - B1004 names all six wrong rotations, B1013 catches it under non-uniform scale. Note that the op's OWN docstring example (a 2m cube rotated 45 degrees against a 1m cube at 1.45,1.45,0 - row B1012) passes on the unfixed addon, because 45 degrees is inside the range where the stale normal still gives the right sign: the documented case exercised only the safe half of the range.","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender behaviour"},{"id":"B8","fact":"**A particle system's viewport child count is `child_nbr` before Blender 4 and `child_percent` from 4 on** - and reading the 4.x name unguarded broke EVERY particle op on 3.6, FIXED 2026-09-14. `_particle_row` read `st.child_percent` unconditionally, and that row is built by add_particles, set_particles AND list_particles, so all three answered \"AttributeError: 'ParticleSettings' object has no attribute 'child_percent'\" on 3.6.23. Worse than a refusal: add_particles creates the system and then builds the row, so each failed call left an orphaned particle system behind that list_particles could not even show.","source":"live, headless Blender 3.6.23, 2026-09-14, asked directly rather than guessed the old name: `dir(ParticleSettings)` there has `child_nbr` and `rendered_child_count` and NOT `child_percent`; 4.2 and 4.4 have `child_percent`. Five orphaned systems accumulated on one object during a single probe (MifPS, ParticleSystem, MifA, MifB, MifC) while list_particles returned nothing. Fix: getattr by NAME, not by version number, in `_child_count`/`_set_child_count` at `tools/blender-addon/MifBlender/ops_particles.py`. Re-runnable: `tools/test_blender_particle_params.py`, which went 6 pass/6 fail on 3.6 before and 12/12 after, and stays 12/12 on 4.4.","engine":"3.6.23","ue":[],"bl":["3.6"],"section":"Blender behaviour"},{"id":"B9","fact":"**On Blender 3.6 `use_shadow` is NOT on `bpy.types.Light` and IS on PointLight, SpotLight, SunLight and AreaLight** - it moved onto the base class in a later build. The `shadow` write guard asked only the base, so create_light and set_light REFUSED `shadow` on every 3.6 light, saying the property does not exist, while list_lights read the same property off the instance and reported `shadowAttr: \"use_shadow\"`. Reader and writer disagreed, and the writer's refusal was the wrong one. FIXED 2026-09-14 by asking the concrete types as well as the base.","source":"live, headless 3.6.23, 2026-09-14, asked directly: `'use_shadow' in bpy.types.<T>.bl_rna.properties` is False for Light and True for PointLight, SpotLight, SunLight and AreaLight. Cost: fourteen assertions in test_blender_scene on 3.6, and no way to turn a light's shadow off on a supported build - bisected to `shadow` by dropping one key at a time from the failing payload. After the fix that suite is 303 pass / 0 fail on 3.6 and unchanged at 303 / 0 on 4.4. Guard at `tools/blender-addon/MifBlender/ops_common.py` refuse_unsupported_shadow.","engine":"3.6.23","ue":[],"bl":["3.6"],"section":"Blender behaviour"},{"id":"B10","fact":"**A geometry-nodes socket IDENTIFIER is generated and differs by build: the same socket is `Input_0` on 3.6.23 and `Socket_2` from 4.4 on** - the older builds number from tree.inputs, the newer from tree.interface. A modifier is addressed BY IDENTIFIER, so a hardcoded 4.x spelling writes a key that does not exist on 3.6 - and that does not raise, because the modifier is an id-property bag: `mod[\"Socket_2\"] = v` CREATES an unused property which then keys and animates perfectly while the real socket never moves. Fcurve present, two keys, value flat. `add_group_interface` now reports `identifier` on every interface row (2026-09-14) so it need not be guessed.","source":"live, 3.6.23 and 5.2.1, 2026-09-14. test_blender_anim A113 had exactly that false pass on 3.6: values at frames 1/12/24 read [0.0, 0.0, 0.0] while list_keyframes showed a two-key curve. Driving the REAL identifier by hand on the same build interpolates 0.0 / 0.5142 / 1.1.","engine":"3.6.23, 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B11","fact":"**Blender 5.2's geometry-node socket move was fixed in ONE of its three call sites.** `ops_nodes._modifier_socket_group` carries the five-build measurement and assign_node_group used it; keyframing (ops_anim) and `list_modifiers` (ops_rig) both still read and wrote the pre-5.2 flat id-property. So on 5.2 you could SET a socket and neither key it nor read it back. Both fixed 2026-09-14 by importing that helper rather than repeating the table. keyframe_insert accepts `modifiers[\"X\"].properties.inputs[\"Socket_0\"][\"value\"]` on 5.2 and rejects both `....inputs[\"Socket_0\"].value` and the bare `[\"Socket_0\"]`.","source":"live, 5.2.1 LTS, 2026-09-14: set_keyframe answered \"could not write 0.0 to 'modifiers[\\\"GeometryNodes\\\"][\\\"Socket_2\\\"]'\" and list_modifiers reported `inputs: {Geometry: null, Lift: null}` - naming every socket and valuing none. After: test_blender_anim 84/3 -> 88/0 on 5.2, and unchanged on 3.6 and 4.4.","engine":"5.2.1","ue":[],"bl":["5.2"],"section":"Blender behaviour"},{"id":"B12","fact":"**On 3.6, assigning an ID a name another ID holds renames whichever of the two sorts FIRST in the datablock list - so it can silently rename the INCUMBENT and leave the assignee holding the plain name.** 4.2 and later always uniquify the assignee. The consequence is that `block.name != wanted`, which every op uses to set `nameWasSuffixed`, cannot fire in the 3.6 incumbent-loses case: the block just named really does have the name asked for, so the caller is told all is well while an object it created earlier has been renamed out from under it.","source":"live headless, all five builds, 2026-09-14. Two objects, one named so it sorts BEFORE the requested name and one AFTER, nothing else different. 4.2.17 / 4.4.0 / 5.0.1 / 5.2.1: the assignee loses in both cases. 3.6.23: assignee-sorts-before loses the assignee, assignee-sorts-after loses the INCUMBENT. Via create_primitive the sort is the OPERATOR'S default name against the caller's, so on 3.6 asking for 'MifT_Clash' displaces through primitive_torus_add ('Torus' sorts after) and not through primitive_cube_add ('Cube' sorts before) - which is why T4001 collided deliberately from the day the op was written and never caught it, and why this first read as a per-kind defect. Cost: test_blender_creation leaked one object per run on 3.6. Fixed by ops_common.assign_id_name, which restores the incumbent and hands the assignee the suffix Blender itself generated.","engine":"3.6.23 / 4.2.17 / 4.4.0 / 5.0.1 / 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour"},{"id":"B13","fact":"**`RegionView3D.update()` is an EXCEPTION_ACCESS_VIOLATION on a headless Blender that has not rendered, and a RENDER is what makes it safe.** It reaches GPU_matrix_frustum_set through rna_RegionView3D_update -> ED_view3d_update_viewmat and writes into a GPU matrix stack a background session has not built. Not catchable from Python: blender.exe dies mid-answer and the caller sees a closed socket, no error, no traceback. frame_viewport and set_viewport_view both called it, via _view_readback.","source":"live headless, 2026-09-14. Fresh `-b --factory-startup` 4.4.0: `frame_viewport {}` as the first call kills the process, crash report names GPU_matrix_frustum_set / view3d_winmatrix_set / ED_view3d_update_viewmat. After ONE 32x32 render in the same session it survives; `bpy.ops.scene.new` does NOT help. Nothing readable distinguishes the two states - area 1586x954, WINDOW region 1586x928, window_matrix[0][0] 1.388889 identical either side of the render - so no property-based guard was available. Same crash on 3.6.23 and 5.2.1. test_blender_scene calls both ops headlessly and PASSES because it renders several times before reaching them, so it only ever exercised the safe state.","engine":"3.6.23 / 4.4.0 / 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour"},{"id":"B14","fact":"**The viewport eye follows three DIFFERENT rules by view mode, and ORTHO is neither of the other two.** PERSP: `view_location + view_rotation @ (0,0,view_distance)`. CAMERA: the scene camera's world position. ORTHO: `view_location` itself - there is no depth offset. This is how `view_matrix.inverted().translation` can be reproduced without touching the GPU, which is what B13's fix needed.","source":"live headless, 3.6.23 / 4.4.0 / 5.2.1, 2026-09-14, in sessions that had rendered so both answers existed to compare. Four orbits including distance 0.5 and an off-axis rotation: PERSP agrees to 2.1e-06. With the orbit at (7,-3,1) d=12 and the camera at (7.3589,-6.9258,4.9583): CAMERA reports the camera exactly, ORTHO reports (7,-3,1) exactly. Using the PERSP form in ORTHO would have put the eye 9.6 units from the view, reported as fact.","engine":"3.6.23 / 4.4.0 / 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour"},{"id":"B15","fact":"**`scene_info detail:true` DROPPED a field instead of only adding one.** The detailed rows were built from `object_info()`, which knows about an object and nothing about a view layer, so asking for MORE detail silently lost `inViewLayer` - the field that says whether the object is in the layer at all, and the one a caller is most likely to be filtering on. Adding `detail:true` to an existing call broke that filter with nothing in the response to say why. **[Reviewed 2026-09-21 against the code - STALE in part: inViewLayer fixed for scene_info only; list_objects detail:true rows have no inViewLayer (ops_scene.py:183-185 vs 249-256)]**","source":"live 4.4.0, 2026-09-14. detail:false row keys `['inViewLayer','name','type']`; detail:true row keys `['camera','isIdentityTransform','locationBU','name','rotationEulerRad','scale','type']` - `inViewLayer` absent from EVERY row, not just some. Top-level keys identical either way, so a key-set comparison of the response would have called the parameter inert and a per-row 'detail adds locationBU' check passes either way; only a SUPERSET assertion catches it. Fixed in ops_scene the same day.","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender behaviour","status":"superseded"},{"id":"B16","fact":"**A particle system with `render_type` COLLECTION and no `instance_collection` renders nothing, exactly as OBJECT with no `instance_object` does - and only the OBJECT half was guarded or reported.** add_particles refused the OBJECT case by name since the op was written; the COLLECTION case was accepted silently, `list_particles.rendersNothing` tested only the OBJECT predicate and answered false, and `instanceCollection` was writable by both ops and reported by neither - so it could be set and never verified.","source":"live headless 3.6.23 and 4.4.0, 2026-09-14, counted through the depsgraph (`object_instances` is what a renderer walks, so an instance source producing none produces no pixels). A 20-particle emitter: render_type COLLECTION with instance_collection None -> **0** instances; with the collection set -> **20**; render_type OBJECT with instance_object None -> 0. Identical on both builds.","engine":"3.6.23 / 4.4.0","ue":[],"bl":["3.6","4.4"],"section":"Blender behaviour"},{"id":"B18","fact":"**`urlopen` raises a BARE `TimeoutError` on a read timeout, so a ComfyUI that accepts the connection and then says nothing produced `TimeoutError: timed out` and named nothing.** CPython calls `h.getresponse()` outside the block that wraps `OSError` into `URLError` (`urllib/request.py`), so ops_gen's `except urllib.error.URLError` arm - which has always carried the good \"cannot reach ComfyUI at ... start it with\" message - never ran for a silent server. Only the *refused* case was ever covered. The two need different advice: refused means start it, silent means it is probably already running and loading a checkpoint, and telling someone to start a second one is how two end up fighting over the port.","source":"measured 2026-09-14, Blender 5.2, `MifBlender` on 8796. Against a genuinely closed port all five gen_ ops answer in 2.0-2.1 s naming ComfyUI and the host - that path was never broken. The bare-TimeoutError path is the one with no message. Fixed in `ops_gen.py` `_post` by catching `TimeoutError` and by testing `URLError.reason` for one, so both spellings reach `_silent()`.","engine":"","ue":[],"bl":[],"section":"Blender behaviour"},{"id":"B19","fact":"**`gen_status` - the op whose whole job is \"is the generator usable right now\" - waited the shared 120 s `_object_info` timeout, making it the SLOWEST op in the file at exactly the moment the answer was \"not reachable\".** A liveness probe that takes two minutes to report a failure is indistinguishable from the hang it is meant to diagnose.","source":"2026-09-14. `_object_info` now takes a `timeout`; `gen_status` defaults to 20 s - still clear of a cold enumerate on a large node install - and accepts `timeout` so a slower install can say so. Measured after: 0.1 s against a healthy ComfyUI, 2.1 s against a closed port.","engine":"","ue":[],"bl":[],"section":"Blender behaviour"},{"id":"B22","fact":"**A scene's MASTER collection is not in `bpy.data.collections`, so a scene has no name any collection-addressed op can resolve.** `bpy.data.scenes.new(\"X\")` gives the scene its own master collection at `scene.collection`, and that datablock never appears in `bpy.data.collections` - so an op that resolves its target with `bpy.data.collections.get(name)` cannot reach it by the scene's name, by the collection's name, or by any other string. The consequence for this addon was total: `create_scene` could make a scene and nothing could put an object into it.","source":"measured 2026-09-14 on Blender 4.4.0 headless, MifBlender on 8796: `create_scene{name:\"MifPopScene\"}` then `link_objects{collection:\"MifPopScene\", object:\"MifPopCube\"}` answers `no collection named 'MifPopScene'. Present: Collection`, and `list_collections` reports only the original scene's `Collection`. Fixed by addressing `scene.collection` directly through a new `scene` parameter on link_objects and unlink_objects (80e911da); `tools/test_blender_scene_removal.py` T903/T904 hold it on 3.6, 4.2 LTS, 4.4, 5.0 and 5.2.","engine":"","ue":[],"bl":[],"section":"Blender behaviour"},{"id":"B23","fact":"**Removing a scene UNLINKS its objects and does not delete them.** `bpy.data.scenes.remove(scene)` takes the scene's master collection with it; every object that was in it stays in `bpy.data.objects` with no users, and is purged on the next save-and-reload rather than immediately. So a count taken after the remove is not what was removed - it has to be read from `scene.objects` BEFORE the call, since that is a view onto a datablock about to be freed.","source":"measured 2026-09-14 across Blender 3.6.23, 4.2.17 LTS, 4.4.0, 5.0.1 and 5.2.1 LTS, by `tools/test_blender_scene_removal.py` T903: link a cube into a fresh scene, `delete_scene`, then `list_objects` still names the cube. 21/21 on all five. The purge-on-reload half is NOT covered - it needs a file round trip, which is why `delete_scene`'s note says \"on the next save and reload\" rather than \"now\".","engine":"","ue":[],"bl":[],"section":"Blender behaviour"},{"id":"B24","fact":"**From Blender 4.2 on, `material.blend_method` is a LOSSY SHIM over `surface_render_method`: four values go in and only two come out.** `sent OPAQUE -> stored HASHED`, `sent CLIP -> stored HASHED`, `sent HASHED -> stored HASHED`, `sent BLEND -> stored BLEND`; and reading back the other way, `surface_render_method = DITHERED` reads as `blend_method` HASHED while `BLENDED` reads as BLEND. **RNA still advertises all four items**, so an enum validation built from `bl_rna.properties[...].enum_items` accepts OPAQUE, writes it, and the material ends up HASHED - the write succeeds and the value is not what was asked for. On 3.6 `blend_method` is real and stores all four.","source":"measured 2026-09-14 by the second seat on Blender 4.2.0 and 4.4.0 through `MifBlender.set_material_settings`, and reproduced here on ALL FIVE builds - 3.6.23, 4.2.17 LTS, 4.4.0, 5.0.1, 5.2.1 LTS - by `tools/test_blender_material_params.py` B1082, which reads the valid set out of the build's own refusal message rather than hardcoding it. CONSEQUENCE FOR US: `set_material_settings` reported this as success because its readback compared only floats (`isinstance(wanted, float)`); every key is compared now and a remap lands in `clamped` with a `why`. Fixed in b0c6edee.","engine":"","ue":[],"bl":[],"section":"Blender behaviour"},{"id":"B25","fact":"**Assigning a validated-by-RNA enum AFTER `bpy.data.*.new()` leaks a datablock on a bad value, and the leak reserves the NAME.** `create_curve` set `data.dimensions` on the line after `bpy.data.curves.new`, so Blender's own enum check raised `TypeError: bpy_struct: item.attr = val: enum \"NOPE\" not found in ('2D', '3D')` with the curve already created: `file_info` reports `orphanCount` 0 -> 1 and `orphans.curves` naming it. By B6 the orphan keeps its name, so a caller who fixes the typo and retries gets `<name>.001` with `nameWasSuffixed:true` and every later lookup by the name they asked for finds nothing.","source":"measured 2026-09-14 on Blender 3.6.23, 4.2.17 LTS, 4.4.0, 5.0.1 and 5.2.1 LTS by `tools/test_blender_creation_params.py` B1046, using `file_info` rather than `purge_orphans` so the evidence survives the measurement. CONTROLS, which are what make it a defect rather than an observation: `splineType` and `align` are both pre-validated on the same op and both refuse leaving 0 orphans - only `dimensions` leaked. Third time this file has learnt it; the same fix (read the set off `bl_rna` BEFORE the datablock) was already applied to `align` and to the font path. Fixed in b0c6edee.","engine":"","ue":[],"bl":[],"section":"Blender behaviour"},{"id":"B225","fact":"**A speaker plays only through an NLA SOUND strip, and only `bpy.ops.object.speaker_add` makes one.** Blender adds one playback per NLA strip of type SOUND on a speaker object, starting at strip.start / fps and ignoring the strip's end (sound.cc, sound_update_base). `NlaStrips.new()` takes an Action and makes a CLIP strip, so a speaker built through bpy.data alone never plays; the operator makes the object AND the SOUND strip, at the scene's current frame. Verified: `add_sound` places its speakers through the operator, and tools/test_blender_audio.py BA102 / BA104 hear each one start on its frame's sample in a mixdown.","source":"`source/blender/blenkernel/intern/sound.cc`; ops_audio.py module docstring; tools/test_blender_audio.py BA102, BA104","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour"},{"id":"B226","fact":"**`Speaker.sound = snd` from Python is not heard until the dependency graph's relations are rebuilt.** The evaluated speaker keeps the original sound (`is_evaluated` False), which is never loaded for playback, so the mixdown is silent; `update_tag()` alone and a frame change do not fix it. `obj.data = obj.data` then `obj.data.update_tag()` does. Renaming the speaker's DATA also loads it (renaming the object does not), which hid the bug from every named test: a mutation deleting the rebuild was caught only by an unnamed speaker. Verified: BA104 builds a speaker the raw way and reads its sound NOT loaded, then `mixdown_audio`'s repair makes it heard at its frame; BA102's unnamed speaker is loaded by `add_sound`'s rebuild.","source":"ops_audio.py docstring; docs/02_GOTCHAS.md (2026-09-23); tools/test_blender_audio.py BA102, BA104","engine":"3.6.23 - 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B227","fact":"**On Blender 5.2 `bpy.ops.sound.mixdown` answers FINISHED before a byte exists, and a background session writes only its first mixdown.** 5.2 starts a window-manager job (sound_ops.cc, WM_JOB_TYPE_SOUND_MIXDOWN) and a worker thread writes the file about 20 ms later. The job is retired only by the UI's job timer, which `--background` never runs, so every later mixdown job in that process stays suspended and never writes, same scene or another. Up to 5.0 the file is written before the operator returns. 3.6 and 4.2 have no `mixrate` or `channels` operator property and mix at `scene.render.ffmpeg.audio_mixrate` / `audio_channels`. Verified: `mixdown_audio` waits for the file's own header and refuses the second background mixdown naming the job - BA106: refused on 5.2.1, written on 3.6-5.0.","source":"`source/blender/editors/sound/sound_ops.cc`; ops_audio.py docstring; tools/test_blender_audio.py BA106","engine":"3.6.23 - 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B228","fact":"**`Scene.use_audio` means opposite things: \"Audio Muted\" on 3.6 (True silences the mixdown), \"Play Audio\" from 4.2 (False does).** The identifier is the same, so read the property's RNA name to know which way it points. Verified: `mixdown_audio`'s mute check reads it that way, and BA104 asserts an audible scene is not called muted on all five builds.","source":"ops_audio.py `_scene_muted`; tools/test_blender_audio.py BA104","engine":"3.6.23; 4.2.17 - 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour"},{"id":"B229","fact":"**A FLAC mixdown silently rewrites the sample format: U8 is written as 16-bit, and S32, F32 and F64 as 24-bit; S16 and S24 are written as asked.** The operator answers FINISHED for all six. Measured 2026-09-23 by reading the written file's STREAMINFO bits-per-sample after `bpy.ops.sound.mixdown(container='FLAC', ...)` on a 1 s tone at 48 kHz: 48,000 samples, stereo, on 3.6.23 and 5.2.1 alike. `mixdown_audio` refuses those four formats for FLAC.","source":"headless probe reading STREAMINFO; ops_audio.py `_FLAC_REWRITES`; tools/test_blender_audio.py BA105 (the refusal)","engine":"3.6.23, 5.2.1 (probe); 3.6.23 - 5.2.1 (refusal)","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B230","fact":"**A sound strip's volume key is not sample-exact: the level reaches the key's value between half a frame before its frame and one frame after it.** Blender samples the curve once a frame; on 3.6-5.0 the change lands on the key frame's first sample or ramps over the frame after it, depending on the frame and identically on those builds (LINEAR and CONSTANT alike); on 5.2 it falls over the half frame before. Placement itself IS sample-exact: a sound at frame F starts at sample (F - frameStart) x rate / fps, for strips and speakers. Verified: `add_sound`'s volumeKeys, BA104.","source":"ops_audio.py docstring (TIME IN A MIXDOWN); tools/test_blender_audio.py BA104","engine":"3.6.23 - 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B231","fact":"**Blender 5.0 removed `SequenceEditor.sequences_all` and `sequences`; `strips_all` / `strips` exist from 4.4.** A reader that fetched the old name through `getattr(ed, \"sequences_all\", [])` counted 0 strips on 5.0 and 5.2 in scenes that had some - `compositor_info` did, and so dropped a blocker. Read by capability. Verified: the version matrix's sound-strip fixture counted 1 strip on 3.6, 4.2 and 4.4 and 0 on 5.0 and 5.2 before the fix; tools/test_blender_refusals.py B116 holds a 5.0-shaped sequencer and fails on the old read.","source":"docs/02_GOTCHAS.md (2026-09-23); tools/test_blender_refusals.py B116","engine":"3.6.23 - 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B232","fact":"**A factory scene renders video with no audio stream: `render.ffmpeg.audio_codec` is NONE on every build** (with `format` MPEG1 and `codec` NONE), so a video renders silent whatever the scene plays until audio_codec names a codec (AAC, FLAC, PCM, ...). That one setting is the mux.","source":"headless `--factory-startup` read of scene.render.ffmpeg on each build, 2026-09-23; render_info and set_render_settings' `video` block (ops_render.py `_video_settings`: carriesAudio, silentNote); tools/test_blender_audio_mux.py BM105 (a NONE render has no soundtrack and bl_extract_audio refuses it)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour"},{"id":"B233","fact":"**From 5.0, `image_settings.file_format = 'FFMPEG'` raises TypeError while `media_type` is IMAGE - the factory value - although FFMPEG is still among the property's static `enum_items`; it is accepted once `media_type` is VIDEO.** 3.6-4.4 have no media_type and take FFMPEG directly. set_render_settings asks the build which media type takes the format and puts both back on a later refusal.","source":"headless probe 2026-09-23 (the assignment refused, media_type VIDEO, then accepted); ops_render.py `_set_file_format`; tools/test_blender_audio_mux.py BM100, BM101 (the rollback after the switch)","engine":"5.0.1, 5.2.1 (3.6.23 - 4.4.0 take it directly)","ue":[],"bl":["3.6","4.4","5.0","5.2"],"section":"Blender behaviour"},{"id":"B234","fact":"**A video render carries the scene's audio at the scene's master volume, linearly.** `Scene.audio_volume` 0.5 put a rendered AAC track 6.02 dB down (-6.0231 measured). A scene normalized by volume alone - 1.0 -> 2.2803, the +7.16 dB its one mixdown asked for - rendered at -23.01 LUFS as MKV + FLAC and as MP4 + H264 + AAC 192 kbps, within 0.01 LU of the WAV normalized by arithmetic (-23.00). `Scene.audio_volume` runs 0-100 on every build (+40 dB at most from 1.0); `ffmpeg.audio_volume` is a separate 0-1 setting.","source":"headless probe 2026-09-23 (volume 0.5, RMS ratio); tools/test_blender_audio_mux.py BM102-BM104 through bl_normalize_audio, set_render_settings, render_animation, bl_extract_audio and mif_measure_audio, the numbers identical on all five builds","engine":"3.6.23 - 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B235","fact":"**A mono sound strip is mixed into a stereo mixdown at -3.01 dB per channel - constant power.** A 0.05-peak (-26.02 dBFS) 1 kHz tone strip reads a true peak of -29.03 dBTP (0.0354) in each channel of the mixdown.","source":"mixdown_audio read by mif_measure_audio: tools/test_blender_audio_mux.py BM102 (the mix's truePeakDbtp, printed per build)","engine":"3.6.23 - 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B236","fact":"**`aud.Sound.write` cannot write a sound decoded from a rendered MP4's AAC track:** \"File couldn't be written, filling the audio frame failed with ffmpeg.\" `aud.Sound(path).limit(t, t + 10).data()` reads the same samples, and Python's `wave` module writes them.","source":"headless probe 2026-09-23 on each build; extract_audio's docstring (ops_audio.py); tools/test_blender_audio_mux.py BM103-BM105","engine":"3.6.23 - 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour"},{"id":"B237","fact":"**An RNA number past its hard range is clamped on assignment, not refused:** `render.ffmpeg.audio_bitrate = 999999` stores 384 (2048 on 5.2.1). set_render_settings' RNA groups report the value kept, the convention of `ops_common.write_rna_property`, so a caller compares `applied` with what it asked for.","source":"tools/test_blender_audio_mux.py BM101","engine":"3.6.23 - 5.0.1: 384; 5.2.1: 2048","ue":[],"bl":["3.6","5.0","5.2"],"section":"Blender behaviour"},{"id":"B239","fact":"**A crossfade between two sound strips, heard frame by frame: add_sound strip volumeKeys [[25, 1], [49, 0]] on one channel and [[25, 0], [49, 1]] on another, in one mixdown_audio at 24 fps / 48 kHz - outside the overlap each tone is 65.9-66.0 dB under the other, over the 23 frames between one falls and the other rises with no frame out of step, and at frame 37 each is at 0.49-0.51 of its full level.** Blender samples the volume curve once a frame, so a LINEAR key is heard as a staircase of frame values.","source":"tools/test_blender_audio_keys.py BK101","engine":"3.6.23, 5.0.1, 5.2.1","ue":[],"bl":["3.6","5.0","5.2"],"section":"Blender behaviour"},{"id":"B240","fact":"**A speaker's volume keyed over time is heard: set_keyframe {dataPath: volume, target: data, interpolation: LINEAR} at frames 61 (1.0) and 85 (0.0) on a Speaker 1 m before the listener camera (distance model NONE) - list_keyframes reads one data curve [(61, 1.0), (85, 0.0)] LINEAR; in the mixdown the 700 Hz is at full level before frame 61, at 0.54 of it at frame 73 on 3.6.23 and 5.0.1 and 0.49 on 5.2.1, and 62.9 dB under it after frame 87.** The half-frame difference at frame 73 is where each build samples the curve.","source":"tools/test_blender_audio_keys.py BK100, BK102","engine":"3.6.23, 5.0.1, 5.2.1","ue":[],"bl":["3.6","5.0","5.2"],"section":"Blender behaviour"},{"id":"B241","fact":"**A mixdown writes every layout it offers, maps a mono source identically on every build, and in 6.1 and 7.1 writes a channel mask that misnames where the sound is.** Layouts: sound.mixdown's `channels` on 4.4+ (MONO, STEREO, STEREO_LFE, SURROUND4, SURROUND5, SURROUND51, SURROUND61, SURROUND71); scene.render.ffmpeg.audio_channels on 3.6 and 4.2 (MONO, STEREO, SURROUND4, SURROUND51, SURROUND71). Each writes its channel count, with WAVE_FORMAT_EXTENSIBLE above two channels and masks 0xb (2.1), 0x33 (quad), 0x37 (5.0), 0x3f (5.1), 0x13f (6.1) and 0x63f (7.1). A mono 1 kHz strip at 0.25 is RMS 0.1768 alone. Mapping, identical on all five builds: STEREO pan -1 is left only (0.1768), 0 is 0.1250 each side; 5.1 pan 0 is the center alone (0.1768), -1 is front left 0.0676 and back left 0.1633 (0.41:1), +-2 (behind) is back left and right 0.1250 each, and the LFE is 0; quad and 5.0 behave the same way. 6.1: behind lands wholly in channel 5, which the mask calls back left; -1 goes to front left and channel 6 (mask: back right). 7.1: behind lands in channels 7 and 8 (mask: side left and right), and -1 goes to front left and channel 5 (mask: back left). Four speakers around the listener camera read the same through mixdown_audio {channels: SURROUND51}: ahead is the center alone (0.2121 for a 0.3 tone), left is 0.0812 and 0.1960, behind is 0.1500 each.","source":"scratchpad explore_channels.py (one fresh session per layout, 34 in all), 2026-09-23 17:4x; tools/test_blender_audio_channels.py BC100-BC102","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour"},{"id":"B242","fact":"**On Blender 5 an Action's F-curves are read through `bpy_extras.anim_utils.action_get_channelbag_for_slot(action, slot).fcurves`, with `action` and `slot` from the object's `animation_data.action` and `animation_data.action_slot`; `action.fcurves` raises AttributeError (the removal in the Blender 5.0 API section above).** Measured on 5.2.1 LTS through run_python: a cube keyed once on location had 3 F-curves by that call, and `action.fcurves` raised \"'Action' object has no attribute 'fcurves'\" - the slip three of round six's B7 runs made (CC32).","source":"live on Blender 5.2.1 LTS, `--background --factory-startup`, MifBlender on port 8914, 2026-09-23","engine":"5.2","ue":[],"bl":["5.2"],"section":"Blender behaviour"},{"id":"B261","fact":"**Cycles' AO bake looks for occluders out to the WORLD's `light_settings.distance` (default 10 on a factory startup), not to anything on the bake operator or the object.** Two 2 m planes 1 m apart, the lower one AO-baked (bake_texture, 64x64, 32 samples): at distance 0.1 the image was white throughout (pixel signature min/max/mean 1.0/1.0/1.0); at 10 it was darker (0.376/1.0/0.688). An enclosed cavity bakes near black at the default. bake_texture `aoDistance` sets it for one bake and restores it; world_info reports it as `aoDistance`.","source":"measured on Blender 5.2.1 headless (--factory-startup), 2026-10-04, by setting `bpy.context.scene.world.light_settings.distance` then `bake_texture {type: AO}`; re-run by tools/test_blender_garment_ops.py GO250","engine":"5.2","ue":[],"bl":["5.2"],"section":"Blender behaviour"},{"id":"B263","fact":"**Blender mesh coordinates and mathutils are float32, so carrying a mesh 240 m from the origin into world space and back moves vertices by micrometres: the resolution there is 2^-16 = 1.53e-5 m.** `transform_selection {space: WORLD}` moving a vertex of a grid at y 240 by -0.1 left it at local -0.600006 (not -0.6); a 5 mm fold built in world space put a ring vertex at -0.489395 for -0.489393. push_out_of, fold_boundary_under, mark_seams and mesh_quality's boundaryLoops work with the object's own origin as the working origin (rotation and scale applied, the translation taken off), and the same fold then lands exactly.","source":"measured on Blender 5.2.1 headless (--factory-startup), 2026-10-04, through the bridge (create_primitive at [0,240,0], transform_selection WORLD, vertex_info); the fix in tools/blender-addon/MifBlender/ops_mesh.py `_shifted`; re-run by tools/test_blender_garment_ops.py GO310-GO312","engine":"5.2","ue":[],"bl":["5.2"],"section":"Blender behaviour","value":"ulp(x) = 2^(floor(log2 x) - 23); 240 in [128, 256): 2^7 x 2^-23 = 2^-16 = 1.526e-5"},{"id":"B264","fact":"**Scaling a UV island about its own centre by k = sqrt(m / r), where r is its UV area over its world area and m the median of r over the islands, gives every island the ratio m - one texel density - and pack_islands with `scale` on then scales all islands by one factor, so equal ratios stay equal.** A 1 m and a 2 m quad on equal 0.4 x 0.4 UV squares (r = 0.16 and 0.04, m = 0.1) came out at spread 1.0 after uv_pack equalizeByArea, the 2 m island exactly twice the other across; without it the pack kept the spread at 4. Areas are UNSIGNED per face: a fold makes a signed area smaller, which is why average_islands_scale left a sleeve at 3x.","source":"measured on Blender 5.2.1 headless (--factory-startup), 2026-10-04: uv_pack then uv_info islands; tools/blender-addon/MifBlender/ops_mesh.py `op_uv_pack`; re-run by tools/test_blender_garment_ops.py GO332-GO333","engine":"5.2","ue":[],"bl":["5.2"],"section":"Blender behaviour","value":"r' = k^2 r = m; side ratio after = sqrt(A3_big / A3_small) = 2"},{"id":"B272","fact":"**`bpy.ops.object.modifier_move_to_index` does not raise when it declines a move - Blender keeps a Multires above a Subdivision Surface - so the stack must be read back to know whether it moved.** A Hook added after a Subsurf moved freely to the bottom; Multires to the bottom stayed at index 0.","source":"measured 2026-10-04 through set_modifier {move: BOTTOM}; tools/test_blender_mesh_edit_ops.py ME504","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"Multires kept at 0"},{"id":"B273","fact":"**A mesh vertex carried to world space and back through 32-bit mathutils loses about 1.5e-5 m at 240 m from the origin: a WORLD move of -0.1 on a vertex at local y -0.5 of an object at y 240 landed at -0.600006; computed in doubles as an offset from the pivot it lands on float32(-0.6), bit-identical to the same edit at the origin.**","source":"measured 2026-10-04 on master's add-on before 5b4e063c and after; tools/test_blender_mesh_edit_ops.py ME210-ME211 (vertex-buffer sha256)","engine":"5.2.1 (fix verified on 3.6.23, 4.4.0, 5.2.1)","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"-0.600006 vs -0.6"},{"id":"B274","fact":"**io_scene_fbx writes the scene-timeline take only when bake_anim_use_nla_strips and bake_anim_use_all_actions are BOTH off; with all-actions off and NLA left at its default (on), a rig with a keyed active action exported no animation curves and no error.** Both default to true. With both on it wrote one take per action, named <object>","source":"5.2: export_fbx_bin.py :2620 (`if not ...use_nla_strips and not ...use_all_actions:` adds the global stack); measured 2026-10-04 through export_mesh, stacks counted in the binary; tools/test_blender_rigging_weights.py RW160","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"<action> (RwAr | Nod, RwAr | Wave); with both off the one take 'Scene'. | 0 AnimCurveNode vs 4"},{"id":"B275","fact":"**Two accesses to the same bone give two Python wrappers, so `bone.parent is other_bone` is never true; compare names.** extract_deform_skeleton's `b.parent is p` left every connected bone unconnected.","source":"measured 2026-10-04 on 5.2.1 (arm connected to spine came out connected False until the compare was by name); tools/test_blender_rigging_weights.py RW170","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"is: False for the same bone"},{"id":"B276","fact":"**bake_to_keyframes' motion check cannot see a bake that lost the motion while the object's constraints still drive it: it compares the object before and after, and Copy Transforms drive both ends.** extract_deform_skeleton replays each baked action with every constraint muted against the source rig instead: 0 position and rotation error at frames 1, 6 and 11 on a rig whose non-deform root carries a 1 m move.","source":"measured 2026-10-04; tools/test_blender_rigging_weights.py RW170","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"0 error, constraints muted"},{"id":"B277","fact":"**Automatic (heat) weights leave a closed box inside a closed box unweighted: of 16 vertices (a 4 m box at x 10 round a 0.4 m one, bones at x 0 and x 3) the inner 8 got no weight and bone A none at all, with no error.** set_parent's NEAREST fallback fills them at 1/d^2 to the bone segments: (9.8,-0.2,0.3) B 0.674909, (10.2,-0.2,0.3) B 0.667351.","source":"measured 2026-10-04 through set_parent ARMATURE_AUTO; tools/test_blender_rigging_weights.py RW150","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"8 of 16 unweighted"},{"id":"B278","fact":"**A geometry-nodes simulation DISK bake with an empty `bake_directory` answers FINISHED, plays back, and writes no file beside the .blend; with `//simbake/` set it writes 500 (250 frames, a .blob and a meta each).** `NodesModifierBake` carries bake_id, bake_mode, bake_target, directory and a frame range - no baked flag; the sign of a bake is playback: jumped to frames 5, 1, 3 a zone adding 1 in Z a frame reads tops 6, 2, 4 baked and 3, 2, 3 live.","source":"measured 2026-10-04 through run_python on a saved file, then bake_physics object diskCache; tools/test_blender_node_zones_sims.py NZ121, NZ123","engine":"5.2.1 (the 500 files with a directory set, and the 6, 2, 4 playback, also on 4.4.0)","ue":[],"bl":["4.4","5.2"],"section":"Blender behaviour","value":"0 files vs 500"},{"id":"B279","fact":"**A node-simulation bake steps the scene through its range, and a cloth in the same scene fills its memory cache with every frame on the way: 250 frames stored, unbaked, where a toFrame 10 alone stores 10.**","source":"measured 2026-10-04; tools/test_blender_node_zones_sims.py NZ122 (the cloth made after the node bake)","engine":"5.2.1","ue":[],"bl":["5.2"],"section":"Blender behaviour","value":"250 vs 10"},{"id":"B280","fact":"**`ptcache.free_bake` on a disk-cached cloth clears is_baked and leaves the 250 frames on disk (framesStored 250 after the free); renaming the cache and stepping to its start frame empties it.**","source":"measured 2026-10-04 through bake_physics object free; tools/test_blender_node_zones_sims.py NZ122","engine":"5.2.1 (none left after the rename on 3.6.23, 4.4.0 and 5.2.1)","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"250 kept"},{"id":"B282","fact":"**A new driver F-curve carries a keyframe at (1, 1) beside its Generator modifier; response keys inserted beside it keep it, so a 0-1.047 map read 0.7136 at mid-range instead of 0.5.** Removing the Generator and every existing key first gives the straight line (0, 0.5, 1 at 0, 0.5236, 1.0472).","source":"measured 2026-10-04 through add_driver mapRange; tools/test_blender_curves_drivers_keys.py CK110","engine":"5.2.1","ue":[],"bl":["5.2"],"section":"Blender behaviour","value":"0.7136 vs 0.5"},{"id":"B283","fact":"**A pose bone turned by a Copy Rotation constraint reads its own rotation_euler (0) through a SINGLE_PROP driver variable and the constrained turn (0.523599 for 30 degrees) through a TRANSFORMS variable, ROT_X, LOCAL_SPACE.**","source":"measured 2026-10-04; tools/test_blender_curves_drivers_keys.py CK110","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"0 vs 0.523599"},{"id":"B284","fact":"**Enabling the bundled Rigify add-on for the session with addon_utils.enable('rigify', default_set=False) half-registers it: RigifyParameters has 3 properties instead of 90 (3.6.23) or 98 (4.4.0, 5.2.1), and generation throws \"'RigifyParameters' object has no attribute 'make_custom_pivot'\" after leaving a 159-bone partial rig; a later disable and enable fails 'already registered'.** create_armature metarig, add_bones sample and the rigify ops enable it with default_set=True and put preferences.is_dirty back.","source":"measured 2026-10-04 in a headless probe, then through create_armature metarig and generate_rigify_rig; tools/test_blender_rigify.py RG110, RG132","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"3 vs 90/98 properties"},{"id":"B285","fact":"**Rigify takes a near-straight limb chain's bend axis from the bone roll: an arm of two 0.3 m bones bent 0.55 degrees gets an IK elbow that does not bend (0.001 m), bent 0.6 degrees it bends the right way (0.163 m), and at 0 degrees generation throws 'zero length vectors have no valid angle'.** The generator's test is","source":"measured 2026-10-04 through generate_rigify_rig; tools/test_blender_rigify.py RG130, RG131","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"y1 x (last.tail - first.head) | < first.length / 100, a 0.573 degree boundary for equal bones; generate_rigify_rig dryRun reports each limb's bend. | 0.55 vs 0.6 degrees"},{"id":"B286","fact":"**On 4.4.0 and 5.2.1 Rigify will not generate a rig none of whose bone collections has a UI row ('No bone collections have UI buttons assigned', raised after the operator ran); 3.6.23 generates the same sample arm. A regenerate removes every bone and bone collection of the target rig before rebuilding, so a bone added to the generated rig is gone afterwards.** generate_rigify_rig refuses the missing row before running, lists the erased bones and keeps a backup rig.","source":"measured 2026-10-04 through add_bones sample, set_bone_collections and generate_rigify_rig; tools/test_blender_rigify.py RG112, RG133, RG134","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"refused vs generated"},{"id":"B287","fact":"**A NODES modifier's field output takes its attribute name at `mod.properties.outputs[identifier][\"attribute_name\"]` on 5.2.1 and at the flat id-property `<identifier>_attribute_name` before it; written, a group's float output 'Mask' appears on the evaluated mesh as a POINT FLOAT attribute of that name.**","source":"measured 2026-10-04 through run_python and assign_node_group outputAttributes; tools/test_blender_small_gaps_rig.py SG140","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"1 attribute"},{"id":"B288","fact":"**An IK pole angle that lays a two-bone chain on its rest pose is the signed angle from the base bone's X axis to the pole plane's projected axis, negative when their cross product points along the bone; with the sign reversed a forward-bent leg's IK knee sat 0.1 m behind the deform knee.**","source":"measured 2026-10-04 through add_control_rig restErrorByChain; tools/test_blender_small_gaps_rig.py SG180","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"0 vs 0.1 rest error"},{"id":"B289","fact":"**On 5.2.1 a light's `use_nodes` reads True straight after False is written - a 5.1+ light always renders its node tree - while 3.6.23, 4.4.0 and 5.0.1 hold False; on all four, switching it off leaves an IES Texture node wired to the Emission strength.**","source":"measured 2026-10-04 through set_light_ies and run_python, one build at a time; tools/test_blender_light_ies_clear.py LI100","engine":"3.6.23, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.4","5.0","5.2"],"section":"Blender behaviour","value":"True after False on 5.2.1"},{"id":"B290","fact":"**On a library override, `modifiers.remove` of a library modifier (`is_override_data` True) returns and the stack reads it gone; after a save and reload it is back.** A modifier added locally (`is_override_data` False) is removed for good. On 3.6.23 a library constraint reads is_override_data False; its path still resolves on override_library.reference.","source":"measured 2026-10-04 through run_python, save and open_file; tools/test_blender_seams_modhosts.py OV100","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"back after reload"},{"id":"B291","fact":"**`modifiers.new` accepts on a CURVES and a POINTCLOUD object NODES (and MESH_SEQUENCE_CACHE from 4.4); on a VOLUME NODES, MESH_TO_VOLUME and VOLUME_DISPLACE, and from 4.4 also every GREASE_PENCIL_* type and LINEART.**","source":"measured 2026-10-04 by calling modifiers.new for every type through run_python; tools/test_blender_seams_modhosts.py MH100","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"1-2 / 3 / 30 types"},{"id":"B292","fact":"**`bmesh.ops.dissolve_limit` at 5 degrees (0.087266 rad - also `mesh.dissolve_limited`'s own default angle, with delimit NORMAL its default) takes a flat 5 x 5 grid from 25 faces to 1; with delimit MATERIAL over two materials to 2; with `use_dissolve_boundaries` to no face at all (2 vertices, 1 edge).**","source":"measured 2026-10-05 through dissolve_mesh_elements mode LIMITED; tools/test_blender_limited_dissolve.py LD101, LD102, LD105","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"25 -> 1 / 2 / 0 faces"},{"id":"B293","fact":"**Texel density in px/m is sqrt(UV area x W x H / world area); a 2 m cube's six SMART islands set to 2.56 px/cm (256 px/m) need 150 percent of a 1024 tile and 37.5 percent of a 2048 tile, and mesh_quality reads 256 px/m on 1024 afterwards.**","source":"measured 2026-10-04 through uv_set_texel_density and mesh_quality; tools/test_blender_texel_density.py TD101, TD102","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"150 % / 37.5 %"},{"id":"B294","fact":"**A UV layout unwrapped for a square tile has texels w/h times longer one way on a w x h texture (texelShape 2.0 on 1024 x 512); stretching V by w/h and packing without rotation makes them square again (texelShape 1.0).**","source":"measured 2026-10-04 through uv_set_texel_density and uv_pack textureSize; tools/test_blender_texel_density.py TD107","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"2.0 -> 1.0"},{"id":"B295","fact":"**`bmesh.ops.bevel` on an edge loop with segments 2, profile 1.0, offset_type OFFSET and loop_slide keeps every loop vertex in place and puts the two new rings the offset away across the faces it touches: 0.2 exactly on an open chain across a grid, 0.05 within 0.001 round a sphere-cylinder Boolean junction with clamp_overlap off; with clamp_overlap on that junction (merged at 1e-5) comes out about 0.034.**","source":"measured 2026-10-05 through offset_edges; tools/test_blender_offset_edges.py OE102, OE103, OE105","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"0.2 / 0.05 / ~0.034"},{"id":"B296","fact":"**Adding a Skin modifier makes mesh.skin_vertices with radius (0.25, 0.25) on every vertex and the first vertex as root; a 3-vertex 2 m chain then evaluates 0.5 x 0.5 x 2.0, and at radius (0.5, 0.25) 1.0 x 0.5 x 2.0.**","source":"measured 2026-10-05 through set_skin_vertices and object_info dimensionsBU; tools/test_blender_skin_vertices.py SK100, SK101, SK103","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"0.25 / 1.0 x 0.5 x 2.0"},{"id":"B297","fact":"**A Mantaflow guide bake needs a GUIDE effector, use_guide on and guide_source EFFECTOR; a DOMAIN-source guide has no stage of its own. On a full Blender install a 5-frame MODULAR gas domain's fluid.bake_guides answers FINISHED and writes fluid_guiding_0001-0005.vdb into the cache's guiding folder, has_cache_baked_guide reads true; after bake_physics freeStages [GUIDES] it reads false and resolution_max writes again.** The PyPI bpy module aborts on the same bake, so this is measured on the installed builds only.","source":"measured 2026-10-06 on a 24-cell domain through add_fluid, set_modifier and bake_physics; tools/test_blender_fluid_guide_freeze.py GF106-GF109","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"5 VDB files, 0.13 s"},{"id":"B298","fact":"**An Ocean modifier's bake ends on a window-manager timer: in a GUI session is_cached reads true right after object.ocean_bake returns its job, and the modifier then plays the baked maps back - each frame its own map, a changed wave_scale ignored until the cache is freed; headless it stays false. An Ocean whose time is not keyed bakes the same displacement map into every frame.**","source":"measured 2026-10-06 through bake_physics {object, modifier} and run_python reads in a windowed --factory-startup Blender on each build, time keyed 1 -> 10 over frames 1-10; tools/test_blender_ocean_bake.py for the headless half","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"disp_0003 == disp_0007 with time unkeyed; cached z range 1.16 / 1.02 at frames 3 / 7 (5.2.1) unchanged by wave_scale x3, 6.27 / 6.05 live after free"},{"id":"B299","fact":"**In a GUI session WindowManager.operators holds operators run from the UI only: an operator run through Python - bpy.ops in a script or call_operator's bpy.ops call - is not added to it.**","source":"measured 2026-10-06 through list_recent_operators in a windowed --factory-startup Blender","engine":"5.2.1","ue":[],"bl":["5.2"],"section":"Blender behaviour","value":"0 rows after a monkey (call_operator) and a cube (bpy.ops in run_python)"},{"id":"B300","fact":"**Rigify generates a human metarig made at scale 3 (bones scaled, object at scale 1) into a rig exactly 3x the scale-1 rig's height with the same bones: 706, Root 1, DEF 160, ORG 159, MCH 167.**","source":"measured 2026-10-18 through create_armature {metarig human, scale 3} and generate_rigify_rig; tools/test_blender_settle_routes.py BS600","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"5.9388 m vs 1.9796 m"},{"id":"B301","fact":"**Rigify's generator reports two metarig faults only after building part of the rig: a faces.super_face bone left with no children throws \"Generation has thrown an exception: 'bpy_prop_collection[key]: key \"ORG-nose\" not found'\" (68-bone partial rig), and a spine head chain (connect_chain) whose first bone is moved off its parent chain throws \"RIGIFY ERROR: Bone 'spine.004': Cannot connect chain - bone position is disjoint.\" after building a 159-bone partial rig. The second rule is ConnectingChainRig.initialize: the first bone's head must lie within 1e-5 of the parent chain's last tail (or, reversed, its first head).**","source":"measured 2026-10-18 through raw pose.rigify_generate in run_python; rigify/rigs/chain_rigs.py:233-240 (3.6), :236-243 (4.2-5.2), utils/bones.py is_same_position / is_connected_position; tools/test_blender_settle_routes.py BS601, BS602","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"68 / 159 bones left; 1e-5"},{"id":"B302","fact":"**Regenerating a Rigify rig erases bones and bone collections added to the generated rig, and writes a new rig_id each time; the UI script is '<rig>_ui.py' and its snap operators are pose.rigify_*_<rig_id>.**","source":"measured 2026-10-18 through generate_rigify_rig, set_bone_collections and rigify_snap; tools/test_blender_settle_routes.py BS603, BS604","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"erased [EXTRA_prop], erasedCollections [Torso_EDITED, MyColl]"},{"id":"B303","fact":"**Blender 5.2's factory preferences have Only Insert Available on (preferences.edit.use_keyframe_insert_available), and Rigify's snap operators key through auto-keying with that flag, so on a rig with no curves a keyed snap writes no key.**","source":"measured 2026-10-18 through rigify_snap {key true}; rigify/utils/animation.py get_autokey_flags (5.2 :110-121); tools/test_blender_settle_routes.py BS604b","engine":"5.2.1","ue":[],"bl":["5.2"],"section":"Blender behaviour","value":"0 curves keyed with it on; keys written with it off"},{"id":"B304","fact":"**Apply (Selected) Pose as Rest Pose does not carry the pose into a skinned mesh: after pose.armature_apply the mesh evaluates to its undeformed shape.**","source":"measured 2026-10-18 through set_bone_rest {applyPoseAsRest, bones}; tools/test_blender_settle_routes.py BS605","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"skin moved 1.7535 posed, 0.0 after"},{"id":"B305","fact":"**object.transform_apply of scale on an armature scales bone lengths, bbone_x, bbone_z and envelope_distance by the scale and keeps each bone's roll, with or without a skinned mesh (applied together the skin does not move).**","source":"measured 2026-10-18 through apply_transform {scale}; tools/test_blender_settle_routes.py BS606","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"x3: lengths 1 -> 3, bbone_x 0.1 -> 0.3, bbone_z 0.05 -> 0.15, envelope 0.25 -> 0.75; roll 0.5 kept"},{"id":"B306","fact":"**paint.weight_from_bones run from Weight Paint mode leaves an armature that was in Pose mode in Object mode when the mesh returns to Object mode; only the selected bones' groups get weights.**","source":"measured 2026-10-18 through set_parent {ARMATURE_AUTO, bones} before its fix; tools/test_blender_settle_routes.py BS607","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"POSE -> OBJECT"},{"id":"B307","fact":"**Create Pose Asset stores, per bone, location, rotation in the bone's mode, scale and ten bendy-bone pose properties (bbone_curveinx/outx/inz/outz, rollin/out, scalein/out, easein/out), plus any property of those bones the rig's action keys on the current frame. Its operator properties: 3.6 and 4.2 activate_new_action, pose_name; 4.4 adds asset_library_reference and catalog_path; 5.0 and 5.2 asset_library_reference, catalog_path, pose_name.**","source":"3.6 scripts/addons/pose_library/pose_creation.py:60-70, :102-144; measured 2026-10-18 through poselib.create_pose_asset and create_pose_asset; tools/test_blender_settle_routes.py BS608, tools/test_blender_poses.py PO160","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"47 curves for two bones (19 without the bendy-bone channels)"},{"id":"B308","fact":"**armature.symmetrize copies an IK constraint onto the mirrored bone with its subtarget and pole_subtarget flipped (hand_ik.L -> hand_ik.R, pole.L -> pole.R); BoneCollection.assign takes a Bone, a PoseBone or an EditBone.**","source":"measured 2026-10-18 through call_operator {armature.symmetrize, mode EDIT} and set_bone_collections; tools/test_blender_settle_routes.py BS609, BS610","engine":"3.6.23 (symmetrize), 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"IK hand_ik.R / pole.R; 3 of 3 kinds"},{"id":"B309","fact":"**A resumable MODULAR fluid cache baked to its end frame reads cache_frame_pause_data at that frame, and a resume after raising the end continues to the new end; fluid.bake_all on an ALL cache bakes the guides with the data. On 4.4.0 the first fluid bake of a guided gas domain raised a pending Mantaflow error from bpy.ops' pre-call view-layer update in 2 of 6 fresh sessions; the same call made again bakes.**","source":"measured 2026-10-18 through bake_physics {stages, resume, end} and the scene-wide bake; tools/test_blender_settle_routes.py BS700, BS701","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"pause 4 -> 8; has_cache_baked_guide true"},{"id":"B310","fact":"**graph.clean, graph.copy / graph.paste(flipped) and pose.copy / pose.paste(flipped) run in a background Blender: the graph operators under a temp_override of window_manager.windows[0] with one of its areas set to GRAPH_EDITOR, pose.paste in pose mode with no editor. graph.clean always keeps a curve's first key.**","source":"measured 2026-10-18 against clean_keyframes, copy_keyframes {flip} and copy_pose {flip}, identical results; tools/test_blender_settle_routes.py BS703-BS705","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"13 keys -> 8, first kept"},{"id":"B311","fact":"**EEVEE ignores light linking on 4.2 (EEVEE Next) and honours it on 4.4, 5.0 and 5.2; MovieDistortion's distortion type is the property distortion_type through 4.4 and the input socket Type from 5.0; the Cell Fracture add-on (object_fracture_cell) is bundled on 3.6 only.**","source":"measured 2026-10-18 through set_light_linking and render_still, add_group_node and enable_addon; tools/test_blender_settle_routes.py BS702, BS710, BS711","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"unlinked cube 0.513 -> 0.513 (4.2), -> 0.468 (4.4+)"},{"id":"B312","fact":"**The sculpt mask is stored in Mesh.vertex_paint_masks (bmesh's paint_mask layer) on 3.6 and in the '.sculpt_mask' FLOAT attribute on points from 4.2.** paint.mask_flood_fill in sculpt mode writes vertex_paint_masks on 3.6.23 and creates .sculpt_mask on 4.2.17 and 5.2.1; a mesh never masked has neither, so a reader must treat it as 0.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_sculpt13b.py SC100-SC110 through sculpt_mask","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"flood 0.7: vertex_paint_masks[0] on 3.6.23, .sculpt_mask 0.7 on 4.2.17 / 5.2.1"},{"id":"B313","fact":"**Blender's mask extract and slice run headless: mesh.paint_mask_extract / paint_mask_slice up to 4.4, sculpt.paint_mask_extract / paint_mask_slice from 5.0 - renamed, not removed.** In sculpt mode with no window they finish; extract makes a new object with a SOLIDIFY modifier (add_solidify), slice removes the masked faces (new_object makes them an object). Neither takes a thickness: it is the Solidify's.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_sculpt13b.py SC200-SC210 through mask_extract","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"upper-half mask of a 482-vertex sphere -> a 225-vertex shell with SOLIDIFY"},{"id":"B314","fact":"**bmesh.ops.symmetrize with direction '-X' copies the -X half onto +X (as mesh.symmetrize's NEGATIVE_X), cutting new vertices where edges cross the plane.** A +X corner raised 0.3 comes back level; the cut's center vertices sit where the old edges crossed x = 0, so the highest point can still be between the halves.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_sculpt13b.py SC400 through symmetrize_mesh","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"cube, +X corner z 1.3 -> 1.0; 8 -> 12 vertices"},{"id":"B315","fact":"**A Geometry Nodes instance draws a temporary object named after the INSTANCER whose data has no pointer back to bpy.data - data.original is itself and is_evaluated is false - but keeps the source mesh's NAME.** An Object Info instance of a Pebble whose mesh is 'Cube.001' reads data 'Cube.001'; geometry the tree made (a Cone node) reads 'Mesh', which no bpy.data list holds. A collection or particle instance draws the real object (instance.object.original is it).","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_instances13b.py IN100-IN120 through mesh_stats instances","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"32 Object Info + 32 Cone instances: data names Cube.001 / Mesh; collection instance: the real KitBall"},{"id":"B316","fact":"**object.duplicates_make_real clears the collection-instance Empty's instance_collection** - a reader that wants to report which collection was realized must read it before the call.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_instances13b.py IN240 through add_collection_instance REALIZE","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"instance_collection None after the call"},{"id":"B317","fact":"**Legacy Grease Pencil edit-mode stroke operators change nothing in a background session on 3.6.23:** in EDIT_GPENCIL mode with the strokes and points selected and the scene on their frame, gpencil.stroke_subdivide and gpencil.stroke_simplify answer FINISHED and the point counts stay as they were. The v3 operators (grease_pencil.stroke_subdivide / stroke_simplify) do change the strokes on 4.4.0 and 5.2.1.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_gpencil13b.py GE100-GE130 through edit_grease_pencil_strokes (written on the stroke data)","engine":"3.6.23, 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour","value":"3.6.23: 20 and 10 points before and after; 5.2.1: 20 -> 39 -> 20"},{"id":"B318","fact":"**bake_grease_pencil_animation polls for a 3D View, and from 4.3 it bakes nothing headless.** gpencil.bake_grease_pencil_animation (3.6) bakes an object keyed over frames 1-4 into four keyframes when run inside a borrowed VIEW_3D area; grease_pencil.bake_grease_pencil_animation (5.2) in the same set-up answers FINISHED and makes an object with no layers. An object's evaluated v3 drawings (modifiers applied) are readable through evaluated_get.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_gpencil13b.py BK100 through bake_grease_pencil","engine":"3.6.23, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.4","5.2"],"section":"Blender behaviour","value":"3.6.23: frames [1, 2, 3, 4]; 5.2.1: layers []"},{"id":"B319","fact":"**trace_image runs headless on every build from 3.6 to 5.2 - 4.2 included, which has gpencil.trace_image although its menus do not list it.** Its properties differ: thickness, resolution, scale and sample exist to 4.2 only, and turnpolicy's BLACK / WHITE became FOREGROUND / BACKGROUND in the 4.3 grease_pencil.trace_image.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_gpencil13b.py TR100 through trace_image_to_strokes","engine":"3.6.23, 4.2.17, 4.4.0, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.2"],"section":"Blender behaviour","value":"a 32 px black square on white -> a new Grease Pencil object with strokes"},{"id":"B320","fact":"**object.convert makes nothing from a Grease Pencil object on 3.6.23 (MESH or CURVE), and no CURVE on 5.2.1; MESH works on 5.2.1.**","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_gpencil13b.py TR100 through convert_object","engine":"3.6.23, 5.2.1","ue":[],"bl":["3.6","5.2"],"section":"Blender behaviour","value":"keep_original conversions: 0 objects added (3.6 MESH / CURVE, 5.2 CURVE), 1 (5.2 MESH)"},{"id":"B321","fact":"**curves.snap_curves_to_surface runs headless in SCULPT_CURVES mode; NEAREST moves each curve so its root sits on the surface and writes the surface_uv_coordinate attribute (CURVE, FLOAT2); DEFORM re-attaches by it after the surface changes.** Curves.add_curves is 4.2+; on 3.6 hair curves come from curves.convert_from_particle_system.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_groom13b.py GR100-GR140 through groom_attach","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"20 roots off a sphere grown 20 %: floating 20 -> 0, for NEAREST and DEFORM"},{"id":"B322","fact":"**A SOCKET_ Empty parented to the first exported mesh, written into an FBX by export_mesh localSpace with EMPTY in object_types, imports into Unreal as a static-mesh socket at R^-1 (p - t) of that mesh, x100 with Y negated** - p the Empty's world location, t and R the mesh's location and rotation; the SOCKET_ prefix is dropped from the name.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_handoff_sockets13b.py SK101 through mif_send_to_unreal (UE 5.8 FbxStaticMeshImport.cpp:2524)","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"a box at (2, 3, 1) turned 90 degrees about Z; Empties 0.5 m along world +X and 0.2 m up, and 0.1 m down: list_sockets Muzzle (0, 50, 20), Grip (0, 0, -10) uu on UE 5.3 and 5.8.3"},{"id":"B323","fact":"**An Unreal AnimSequence exported as FBX and imported into Blender with its animation comes in as one armature named after the top bone with one action holding every bone; Unreal key k plays at Blender frame k + 1, the importer sets the scene's fps to the clip's, and every bone head equals get_anim_pose's component-space location (Y negated, m to cm).** Unreal's exporter writes no file for a clip with no preview mesh on it or its Skeleton and no compatible mesh found (UE 5.8 Editor/UnrealEd/Private/EditorExporters.cpp:2461-2477); with PreviewSkeletalMesh set it does.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_handoff_anims13b.py AN101 through mif_send_to_blender animations (UE 5.3.2)","engine":"4.2.17, 5.2.1","ue":[],"bl":["4.2","5.2"],"section":"Blender behaviour","value":"25 bones x 5 keys x 2 clips: worst 6e-05 uu; scene fps 24 -> 30"},{"id":"B324","fact":"**Assigning a second action to an armature leaves every pose channel the new action does not drive at the value the previous action left there.** A clip imported from Unreal that left the root at its reference pose played with the previous clip's root turn until the pose bones were reset to rest first (assign_action restPose).","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_handoff_anims13b.py AN101 and AN104","engine":"4.2.17, 5.2.1","ue":[],"bl":["4.2","5.2"],"section":"Blender behaviour","value":"165 cm off at the head without the reset, 1e-05 uu with it"},{"id":"B325","fact":"**A Level Sequence camera exported with export_sequence_fbx imports as one camera whose object action is baked every frame and whose data action keys lens only at Unreal's own keys, so Blender interpolates the focal length linearly where Unreal's default cubic auto tangent does not;** the same data action keys dof.focus_distance at 0, and on 3.6 a curve emptied of keys still drives the focus to 0 until it is given one key.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_handoff_camera13b.py CA101-CA102 through mif_send_camera_to_blender (UE 5.3.2)","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"24 -> 85 mm over a second: 82.97 mm in Blender against 84.80 in Unreal a frame before the last key; focus 0 m on 3.6"},{"id":"B326","fact":"**Blender's glTF exporter (Standard lighting mode) converts a point or spot light of P watts to P / 4pi x 683 candela and a sun of E W/m^2 to E x 683 lux - a spot as a point masked by its cone - and glTF has no area light.**","source":"Blender 5.2 scripts/addons_core/io_scene_gltf2/blender/com/conversion.py:10; blender/exp/lights.py:160-176","engine":"5.2.1","ue":[],"bl":["5.2"],"section":"Blender behaviour","value":"PBR_WATTS_TO_LUMENS = 683; emission_luminous = emission_strength / (4 * math.pi) for non-sun lights"},{"id":"B327","fact":"**mesh.knife_project answers FINISHED and cuts nothing in a background session** even inside a borrowed VIEW_3D area with its region_3d set to an orthographic top view - the region has no size, so the projection meets nothing.","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_knife13b.py (the measurement before knife_project was built)","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"a 32-point ring over a one-face plane: 1 face before, 1 after"},{"id":"B328","fact":"**The sequencer's retiming data API is not safe to drive:** RetimingKeys.add(timeline_frame=) on a movie strip drops the connection (Blender exits) on 4.2.17, 4.4.0 and 5.0.1; on 5.2.1 it adds the key plus the strip's start and end keys, and a moved key clamps at its neighbour. The operators run headless in a SEQUENCE_EDITOR override, but retiming_key_add then retiming_segment_speed_set(speed=200) left a 12-frame clip 9 frames on 4.2 and 6 on 5.2.","source":"measured 2026-10-26 in headless --factory-startup Blenders on a private port (scratchpad H6_m_rt.py, H6_m_g2.py); the reason edit_strip retiming was declined","engine":"4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"keys [1, 5, 9, 13] after two adds on 5.2.1; lengths 9 (4.2.17) and 6 (5.2.1)"},{"id":"B329","fact":"**A new MaskSpline starts with one point**, so points.add(n) makes n + 1; a mask built in bpy.data.masks needs no editor context and wires into a compositor Mask node or a strip's MASK modifier (input_mask_type ID).","source":"measured 2026-10-25 in headless --factory-startup Blenders on a private port; tools/test_blender_mask13b.py MK101 through create_mask","engine":"3.6.23, 4.2.17, 5.2.1","ue":[],"bl":["3.6","4.2","5.2"],"section":"Blender behaviour","value":"points.add(4) on a new spline: 5 points"},{"id":"B330","fact":"**A 5-degree Limited Dissolve over the faces an EXACT boolean_op cut merges none of them when the cutters are plain boxes: the EXACT solver already leaves the fewest faces its outline allows, and the n-gons round a hole are there because a Blender face cannot have a hole.** boolean_op cleanup reports it as facesDissolved 0: twelve joined slit boxes cut from a 48-sided cylinder left 132 cut faces, 48 of them n-gons; a box intersected with a UV sphere of radius 1.25 left 230, 54 of them n-gons. A cutter whose own faces are split does give it work: a 3 x 3 gridded notch, 18 faces to 8.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR100, GR102, GR103 (2026-10-30, the same numbers on each build)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"facesDissolved 0 of 132 and 0 of 230; 10 on the gridded notch"},{"id":"B331","fact":"**Splitting a cut's n-gons - BEAUTY triangulation, then triangles joined back into quads within 40 degrees - leaves the mesh's triangle count unchanged and the faces the cut never reached as they were.** boolean_op cleanup (ngons quads, the default) took the slit cylinder from 50 n-gons to its 2 caps at 524 triangles before and after, the box-and-sphere intersect from 54 to 0 at 628 and 628, and a door and window cut from a wall as a list to 0, each still closed (0 open, 0 non-manifold edges). An engine splits an n-gon on import anyway, so the split decides how, and never meets a triangle budget.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR100, GR101, GR102 (2026-10-30)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"524 = 524, 628 = 628 triangles; n-gons 50 -> 2, 54 -> 0"},{"id":"B332","fact":"**Sending mesh_quality gameReady's named fixes can bring back a reason another fix cleared, so the check is run again after each.** A 48-sided cylinder of radius 1 scaled [1, 1, 2] against a budget of 100 failed on triangles 188, ngons 2 and scaleNotApplied; apply_transform scale and clean_mesh triangulate cleared theirs, then dissolve_mesh_elements mode LIMITED (the first triangle fix) merged the triangulated caps back into 2 n-gons and decimate_mesh targetTris 100 reached 100 triangles; the next check named clean_mesh triangulate, and after it the verdict passed.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR110, GR111 (2026-10-30)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"2 rounds from the third check to a pass at 100 triangles"},{"id":"B333","fact":"**Mesh_quality gameReady with no triangleBudget holds the count to the top of conventions' usual range for the mesh's size class: 1070 small (under 100 cm on its longest side), 3364 medium (to 300 cm), 25652 large.** A 64 x 32 UV sphere of radius 0.25 m (3968 triangles) fails triangles at a limit of 1070 with triangleBudgetFrom sizeClass small; 0.5, 2 and 5 m cubes get 1070, 3364 and 25652, each equal to conventions.triangles.usualRange's top in the same reply; a given triangleBudget says from given.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR130, GR131 (2026-10-30, the same numbers on each build)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"3968 > 1070; 1070, 3364, 25652"},{"id":"B334","fact":"**A face far over degenerateFaces' 1e-9 m2 can still be a needle, and clean_mesh dissolveDegenerate given a distance dissolves faces up to that width.** A 1 m box whose top is fanned from a point 2e-6 m off an edge fails gameReady sliverFaces 1 (narrower than 1/10000 of the longest side, 100 or more times longer than wide) with the fix clean_mesh dissolveDegenerate 1e-05; after it no sliver and no near-zero tangent remain and the box is closed (0 open, 0 non-manifold edges); the n-gon the collapse leaves is named next (clean_mesh triangulate) and the check then passes. A 3 m pole of 20 sides, triangulated, and a 2 cm torus have no sliver.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR134, GR135 (2026-10-30)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"width 2e-6 m; sliverFaces 1 -> 0"},{"id":"B335","fact":"**gameReady floatingParts is a part under 1% of the largest part's surface that touches no other part, so a block set on or through another is not floating and one buried inside is.** A 5 cm cube 0.45 m above a 2 m box in one mesh: area share 0.000625, gap 0.45 m, fix delete_mesh_elements with its 6 faces, after which the box is 6 faces, closed, passing. 10 cm blocks resting on the box and crossing its top, and an equal box 3 m away, are not floating; a 10 cm block buried in the box's middle, 0.95 m from every wall, is.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR132, GR133 (2026-10-30)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"gap 0.45 m and 0.95 m"},{"id":"B336","fact":"**gameReady nearZeroTangents reads mesh_quality's tangents check and names the fix by cause.** A cube with one face's UVs on a single point gives 4 vertex instances Unreal would give nearly zero tangents; the reason names uv_unwrap (the faces are not slivers) and after it the check passes. On the needle box of the sliver row the tangent reason names the sliver fix instead.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR136, GR134 (2026-10-30)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"4 -> 0"},{"id":"B337","fact":"**bevel_edges finish leaves the flat faces shading flat: their shading normals on the evaluated mesh lean 0.000 degrees.** A 1 m cube, offset 0.02, no selector: the 12 edges 30 degrees or more off flat are beveled with hardened normals and face strength AFFECTED, every face smooth, a Weighted Normal modifier with face influence; flat faces 0.000, the bevel's faces 48.9 on average; triangles 12 -> 44 against the medium class's 3364, and gameReady passes. A 32-sided cylinder: its two rims only (64 edges), its 2 caps 0.000, 124 -> 252 triangles; with finish triangleBudget 50, withinBudget false and the bevel written.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR150, GR151, GR152 (2026-10-30)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"0.000 degrees; 12 -> 44; 124 -> 252"},{"id":"B338","fact":"**A Cycles DIFFUSE bake (pass COLOR) is the diffuse pass, base color x (1 - metallic), so a metallic 1 cube bakes black: it is not the albedo.** bake_texture DIFFUSE on a cube whose Base Color is (0.6, 0.3, 0.1) and Metallic 1 wrote a red mean of 0.0; BASE_COLOR, which routes the Base Color input through a temporary emission, wrote 0.645 (the whole 32 px map, margin and empty texels included). A worn paint's bare metal edges bake black the same way, so a weathered metal is baked BASE_COLOR.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_weathering.py` WE104 (2026-10-30; DIFFUSE under 0.02 and BASE_COLOR over 0.4 on every build)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"DIFFUSE red mean 0.0, BASE_COLOR 0.645 (5.2.1)"},{"id":"B339","fact":"**A Bevel node's curvature (1 - the beveled normal . the geometric normal, the Bevel radius the band's width) is an edge mask that needs no dense mesh: on an 8-vertex 1 m cube, weathering's edge wear changed the baked color only along the edges.** set_material_properties weathering (edgeWear width 0.02, colorVariation 0) baked BASE_COLOR at 128 px: the mean color change within 3 cm of an edge (11% of the texels) 0.041, on the face centres over 10 cm from one (64%) 0.000; METALLIC (edgeWear metallic 1) 0.090 near the edges and 0.000 on the centres. The node is a Cycles node, so EEVEE shows no edge wear and every bake does.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_weathering.py` WE101, WE102 (2026-10-30; near over 5x flat on every build)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"color change 0.041 near, 0.000 flat (5.2.1)"},{"id":"B340","fact":"**An Ambient Occlusion node with only_local finds a cavity in the object itself and nothing on a convex one, which makes it a grime mask that needs no neighbours.** weathering grime alone (roughness 0.95, amount 0.6, distance 0.2 m) on a 1 m cube whose top is inset 0.15 and pushed 0.1 in, baked ROUGHNESS at 128 px: peak 0.949; on a plain cube with the same material, base 0.4: peak 0.447 - the 0.05 roughness variation, no grime.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_weathering.py` WE103 (2026-10-30; recess over 0.85 and plain under 0.5 on every build)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"0.949 against 0.447 (5.2.1)"},{"id":"B342","fact":"**mesh_quality gameReady no longer judges UVs past the 0-1 tile unless asked with uvOneTile, because the add-on's own tiling unwrap puts them there.** A box 3 x 1 x 2 unwrapped by uv_unwrap BOX metersPerTile 1 has corners outside 0-1 and now passes, with the count in notJudged as uvLoopsOutside01; under uvOneTile the same mesh fails uvOutOfBounds with the same count, and the named SMART unwrap clears it. Before the change a cube unwrapped the same way failed uvOutOfBounds with 18 corners on 5.2.1, and the fix it named would have replaced the tiled layer.","source":"`MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR114, GR121 (2026-11-01, 41 pass 0 fail on each build)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"15 corners outside on 5.2.1, pass by default, fail under uvOneTile"},{"id":"B343","fact":"**uv_info held every island pair whose UV boxes overlap and walked each island's triangles per pair, so an atlas of stacked cards ran away, and mesh_quality reaches it through conventions.** On exported foliage: 36,014 triangles took 211 s and now 2.7 s; 185,768 triangles passed 9 GB in 18 s (stopped there) and now finish in 11 s at 0.83 GB committed. Past 200,000 box pairs or 20M box tests the search stops, overlapCheckCapped is true, the counts are lower bounds and lightmapReady is false.","source":"stack samples and committed memory in a background Blender 5.2.1 on three exports (2026-11-01); `MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR122 (44 pass 0 fail on each build); tools/blender-addon/MifBlender/ops_mesh.py _uv_box_pairs","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"211 s to 2.7 s; 9 GB to 0.83 GB","newer":true},{"id":"B344","fact":"**gameReady's floatingParts at a 1% area share missed two side braces hanging 100 mm from everything at 2% of the frame; at 10% they fail and carts with no floating part still pass.** Each brace: 28 faces, area share 0.0199 of the largest part, nearest other part 100 mm, crossing nothing. Two other carts, whose smallest clearances are parts that cross or touch, pass. A block at 16% share 2 m away stays a part of its own.","source":"one finished cart per model read in a background Blender 5.2.1 (2026-11-01); `MIF_BLENDER_PORT=<port> python tools/test_blender_game_ready.py` GR136 (73 pass 0 fail on each build)","engine":"3.6.23, 4.2.17, 4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":"Blender behaviour","value":"share 0.0199, gap 100 mm"},{"id":"Maab8b3","fact":"Move the EDITOR, not the Blender addon, when a port collides: the addon's port lives in two places that must agree (its Blender preference and MIF_BLENDER_PORT) while the editor's is one environment variable. MifBridge warns loudly - but does not refuse - when configured for 8792 or 9876, because the port might be free on a machine with no Blender and a plugin that will not start is worse than one that says what it suspects.","source":"MifBridge.cpp - PORT ALLOCATION CHECK comment :82-92, `if (Port == 8792)` warn :93-103, `else if (Port == 9876)` warn :104-112. CITATION CORRECTED: candidate said :69-85, which is the MIF_BRIDGE_PORT parse, not the guard. docs/17_PORTS.md § Which side moves and § The guard.","engine":"","ue":[],"bl":[],"section":"MifBridge ports"},{"id":"M1c64d2","fact":"The Blender arm is ONE serialised socket guarded by _BL_LOCK, and its timeout ladder must keep the addon giving up FIRST: MCP connect 3s (MIF_BLENDER_CONNECT_TIMEOUT), MCP probe read+lock 5s (MIF_BLENDER_PROBE_TIMEOUT), addon main-thread job 150s (MifBlender/server.py DEFAULT_JOB_TIMEOUT), MCP work read 180s (MIF_BLENDER_TIMEOUT). Inverted - as it was at 600s against 180s - the MCP abandons the call while Blender keeps mutating the scene for another seven minutes.","source":"docs/00_ARCHITECTURE.md § Timeout ladder (:129-140). Verified at source: tools/mcp-server/server.py BLENDER_CONNECT_TIMEOUT default 3.0 (:108), BLENDER_PROBE_TIMEOUT 5.0 (:109), BLENDER_TIMEOUT 180.0 (:110), the 150s DEFAULT_JOB_TIMEOUT note at :4453; _BL_LOCK declared :293 with the persistent-connection comment at :27","engine":"","ue":[],"bl":[],"section":"MifBlender transport","value":"`3s connect < 5s probe < 150s addon job < 180s MCP work read`"},{"id":"Mdf37ab","fact":"From 4.4 an action keeps its curves per SLOT and an object plays only the slot bound to it. `animation_data.action = act` on a SECOND object leaves its action_slot None (action_suitable_slots lists the first object's slot) and it does not move; setting action_slot to that slot makes both play, and the slot's users() lists both. MifBlender's assign_action now binds it.","source":"throwaway --background --factory-startup sessions, 2026-09-13 (described in commit 8333b94); regression test tools/test_blender_anim.py A110 (the second object's motion read through evaluate_at_frame); handler tools/blender-addon/MifBlender/ops_anim.py op_assign_action slot block","engine":"4.4.0, 5.0.1, 5.2.1","ue":[],"bl":["4.4","5.0","5.2"],"section":"Blender animation / slotted actions","value":"`B.animation_data.action = act -> B.action_slot None, B.x at frames 1/6/11 = 0, 0, 0; B.action_slot = act.slots[0] -> 0, 2.5, 5.0; act.slots[0].users() -> [A, B]`"},{"id":"M2b9a1b","fact":"A Render Layers node's `layer` is a DYNAMIC enum: the item list read off the node CLASS is only 'PLACEHOLDER', so validating a value against it refuses every real view layer. Assign the value and let Blender's TypeError name the members that instance offers.","source":"live on scratch 4.4, 2026-09-13; tools/test_blender_scene.py S132; tools/blender-addon/MifBlender/ops_nodes.py _write_node_property ENUM branch (8db82c0). Same class as S115 (view transform, 'Available: NONE')","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender compositor / dynamic enum","value":"`add_group_node CompositorNodeRLayers {layer: 'ViewLayer'} -> \"Available: PLACEHOLDER\"` before the fix"},{"id":"M2e3b94","fact":"CompositorNodeSplit (axis X) shows its FIRST Image input RIGHT of the factor line and its SECOND input LEFT of it. Both inputs are named 'Image', so link_group_nodes reaches the second by index ('1').","source":"rendered on scratch 4.4, 2026-09-13; encoded in the committed passes scene, tools/blender_showcase_scenes6.py _compositor_split (2c5fdf8) - re-render that scene to check","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender compositor / Split node","value":"`factor 33: input '1' fills x < 33%, input '0' fills x > 33%`"},{"id":"M505c8c","fact":"Blender refuses to save over a .blend the session has appended from: the appended file stays in bpy.data.libraries for the rest of the session.","source":"observed rebuilding the roundtrip scene twice in one session, 2026-09-13; the per-build file name and its reason in tools/blender_showcase_scenes6.py roundtrip (aae9bb1)","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender files / libraries","value":"`save_file -> \"Error: Cannot overwrite used library '<path>'\"`"},{"id":"M931745","fact":"One object out through each format and back: glTF splits a vertex at every seam, and clean_mesh at 0.0001 welds it back to the original count with an identical render; USD import names the UV layer 'st'; STL, PLY and Alembic come back with NO material slots; every format kept the silhouette.","source":"tools/blender_showcase_scenes6.py roundtrip header comment and scene (aae9bb1) - scene_probe roundtrip reproduces it","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"Blender IO round trips","value":"`glTF: cylinder 48 -> 144 verts, rocket 451 -> 720 -> 451 after clean_mesh (render diff 0.0); USD UV 'st'; STL/PLY/ABC slots []; silhouette IoU 1.0 for all 8`"},{"id":"Mdc75c2","fact":"clear_scene, even with purgeOrphans, removes objects but NOT the scene's compositor tree or its links, its collections, or its view layers: a rebuild in one session inherits all three.","source":"tools/blender_showcase_scenes6.py _compositor_split docstring and the passes rebuild cleanup (2c5fdf8)","engine":"4.4.0","ue":[],"bl":["4.4"],"section":"MifBlender clear_scene","value":"`after clear_scene: compositor still had Split nodes and Render Layers.Alpha -> Composite.Alpha; create_collection 'Stage' refused as existing`"},{"id":"M636858","fact":"In a fresh background (--factory-startup) session, bpy.ops.object.shade_auto_smooth returns {'CANCELLED'} and adds no Smooth by Angle modifier on 4.2.17 and 4.4.0 - with a context override or without, three tries running - while 5.2.1 returns {'FINISHED'} and adds it first time. Mesh.set_sharp_from_angle(angle) exists on all three and marks the sharp edges directly. MifBlender's set_shading now reads the operator's answer and falls back to it.","source":"throwaway sessions, 2026-09-13 (described in commit 95aa84e); tools/test_blender_scene.py S133; handler tools/blender-addon/MifBlender/ops_query.py op_set_shading auto-smooth block","engine":"4.2.17, 4.4.0, 5.2.1","ue":[],"bl":["4.2","4.4","5.2"],"section":"Blender shading / auto smooth","value":"`4.2.17 / 4.4.0: shade_auto_smooth -> {'CANCELLED'}, modifiers []; 5.2.1 -> {'FINISHED'}, [Smooth by Angle]; set_sharp_from_angle(35 deg) on a cube -> sharp_edge 12 of 12`"},{"id":"B92","fact":"**This Blender's render-engine enum offers `BLENDER_EEVEE`, `BLENDER_WORKBENCH` and `CYCLES` - there is no `BLENDER_EEVEE_NEXT`.**","source":"2026-09-17, `set_render_settings`. Worth a row because 4.2-4.5 renamed EEVEE to `BLENDER_EEVEE_NEXT` and 5.0 renamed it back, so a version-derived guess is wrong in both directions.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"MifBlender API contracts and rig geometry","value":"`\"unknown render engine 'BLENDER_EEVEE_NEXT' for this Blender. Valid: BLENDER_EEVEE, BLENDER_WORKBENCH, CYCLES. NOTHING was changed.\"`"},{"id":"B94","fact":"**`add_modifier` treats `name` as an alias for `object`; the modifier's own name is `modifier`. Modifier settings are CURATED per type** and the refusal enumerates the accepted ones. SOLIDIFY accepts `thickness` only; ARMATURE accepts `object` only. **[Reviewed 2026-09-21 against the code - STALE in part: Since 16eabc6e (2026-09-21) settings take curated names OR any writable RNA property by identifier (ops_rig.py:1098-1105, 1129-1137, 1166-1173, 1352-1447)]**","source":"2026-09-17, probed by sending a deliberately unknown settings key to each type - the refusal lists that type's own.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"MifBlender API contracts and rig geometry","value":"`\"'object' and 'name' are two names for the same parameter and were given different values\"`; `\"Accepted for SOLIDIFY: thickness\"`; `\"Accepted for ARMATURE: object\"`; `\"Accepted for SIMPLE_DEFORM: angle, deformAxis, deformMethod, factor, origin, vertexGroup\"`","status":"superseded"},{"id":"B96","fact":"**`gen_image` and `gen_texture` hold Blender's MAIN THREAD for the whole ComfyUI run**, so MifBlender answers nothing while a texture is being made, and the addon's default main-thread `job_timeout` is shorter than a first Flux load.","source":"measured 2026-09-18: `gen_image` (flux1-schnell-fp8, 1024x1024, 4 steps) returned `\"timed out after 150s waiting for Blender's main thread\"` while ComfyUI's `/queue` still listed the job running and its log showed step 1 of 4 at **97.43 s/it** - the model load alone. Raising `preferences.job_timeout` to 3600 and calling `MifBlender.start_server(job_timeout=3600)` fixes it, at the cost of dropping the current socket. The first load is the expensive one; later steps ran at 22 s/it and below.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"B100","fact":"**`gen_image` cannot be used for a BATCH, by construction.** It holds Blender's main thread for the whole ComfyUI run (B96), and a caller gets **150 s** to acquire that thread - far less than a cold Flux load. So the first call in a batch succeeds and every call after it fails, no matter how large the op's own `timeout` parameter is: that parameter bounds the JOB, not the main-thread wait. Worse, a timed-out call **may already have queued its prompt**, leaving ComfyUI generating an image no caller is waiting for. **[Reviewed 2026-09-21 against the code - STALE in part: 150 s covers queue + run in a GUI Blender; no add-on timeout under -b (addon server.py:767-769, 783-801); job_timeout bounds the whole job (queue + execution) only in an interactive Blender; a lone first call over 150 s times out too (B96). Advice not to batch still holds (addon server.py:74-76, 765-769, 783-801)]**","source":"measured 2026-09-18: a nine-surface run returned one texture and **eight** consecutive `\"timed out after 150s waiting for Blender's main thread\"` while `timeout:900` was being passed on every call and the client allowed 1200 s. ComfyUI's `/queue` then showed `running 1, pending 1` - two orphans from calls that had already reported failure. A `describe_op` issued during the run also hung past 120 s, confirming the addon answers nothing at all while generating. **The route that works** is to queue the prompts straight to ComfyUI's HTTP API, which is a separate process and holds no Blender lock.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE","status":"superseded","replacedBy":"B96"},{"id":"B118","fact":"**MifBlender has no `self_audit`.** The full op list comes back inside the ERROR of an unknown endpoint, and `describe_op` takes `op`, not `name`.","source":"measured 2026-09-18: `self_audit` returned `{ok:false, error:\"unknown endpoint 'self_audit'. Closest: audit_placement. Known: add_bones, add_cloth, ...\"}` - 189 names in that one string. `describe_op {name:'run_python'}` was refused with `\"describe_op: unknown param(s) name. Accepted: op\"`.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"B120","fact":"**Blender 5.0.1 has removed `Mesh.use_auto_smooth`.** The replacement is the operator `bpy.ops.object.shade_auto_smooth(angle=...)`, which writes sharp-edge attributes rather than adding a modifier. A `BEVEL` modifier with `harden_normals=True` needs that smoothing to be visible at all.","source":"measured 2026-09-18 by a re-runnable probe (`scratchpad/bevel_probe.py`, run through MifBlender `run_python`) on a fresh 8-vertex cube: `hasattr(mesh, 'use_auto_smooth')` -> False, `hasattr(bpy.ops.object, 'shade_auto_smooth')` -> True, and the operator succeeded under `bpy.context.temp_override(object=ob, active_object=ob, selected_objects=[ob], selected_editable_objects=[ob])`. Afterwards `[m.type for m in ob.modifiers]` was `['BEVEL']` only - the smoothing added no modifier of its own.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"B122","fact":"**MifBlender can both bake PBR maps and return a render inline**, so a prop can be reviewed and textured without leaving Blender: `bake_texture` accepts `bakeType, uvLayer, filepath, width, height, samples, device, margin, object, imageName, keepNode`, and `render_still` accepts `filePath, resolutionX, resolutionY, samples, percentage, writeStill, returnImage, previewMaxPx, previewMaxBytes, frame, output`. `set_material_properties` writes Principled BSDF values by name, resolving them per Blender version.","source":"read 2026-09-18 from `describe_op {op:'bake_texture'}`, `{op:'render_still'}` and `{op:'set_material_properties'}` against the running addon on 127.0.0.1:8802. NOTE: `describe_op` takes `op`, NOT `name` - see B118.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"B123","fact":"**`bake_texture` detects Cycles' silent-success case.** `bpy.ops.object.bake` returns FINISHED, writes nothing and reports no error when there is no active image-texture node to bake into; the op fills the target with a magenta sentinel first and refuses if the sentinel survives, so a no-op is distinguishable from a legitimately black bake. It also refuses a bake with no UV layer, and refuses to write a DATA map (normal/roughness) if the colour config offers no non-colour transfer, rather than producing a map that is wrong in every engine while looking right.","source":"read 2026-09-18 from `describe_op {op:'bake_texture'}` against the running addon on 8802 - the refusal strings are the citation and each names what it refuses and that NOTHING was baked.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"B125","fact":"**The Principled BSDF's emission inputs in Blender 5.0.1 are `Emission Color` (RGBA) and `Emission Strength` (VALUE).** EEVEE in 5.0.1 has **no** `use_bloom` - that attribute is gone - but does have `use_raytracing`. An emissive material therefore reads as ordinary pale paint unless its strength is high enough to survive the scene's key light and the view transform; there is no bloom switch to rescue it.","source":"measured 2026-09-18 by a re-runnable probe run through MifBlender `run_python` on 8802, dumping `[(i.name, i.type) for i in bsdf.inputs]` from a fresh Principled node and `hasattr(scene.eevee, 'use_bloom')` / `hasattr(scene.eevee, 'use_raytracing')`. The full socket list also confirms `Specular IOR Level`, `Transmission Weight`, `Coat Weight` and `Sheen Weight` as the 5.x names. Practical consequence measured in the same session: a VU meter at strength 2.4 rendered as cream paint and had to go to 9.0 to read as lit under a three-point rig.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"B127","fact":"**Blender's shader Noise is GAUSSIAN ABOUT 0.5, not spread over 0..1.** At detail 9 / roughness 0.62 its output has mean **0.4998**, sd **0.0583**, and never leaves **0.262..0.731**. Any mask expressed as \"a fraction of the surface\" must therefore be thresholded at `mean + z(fraction)*sd`, NOT at `1 - fraction`: a threshold of 0.84 for a nominal 10% coverage is four standard deviations above the mean and above the noise's maximum, so the mask is zero everywhere.","source":"measured 2026-09-18 by a re-runnable probe: `bake_plane.py` with `probeNoise` builds a material whose EMISSION is the raw noise Fac, baked EMIT to 512 px through `bake_texture` on 8802, and the histogram read back in LINEAR (sRGB-decoded) with PIL. Empirical quantiles - the value above which a given fraction lies - were 0.02 -> 0.6172, 0.05 -> 0.5972, 0.10 -> 0.5776, 0.20 -> 0.5520, 0.50 -> 0.4969; the Gaussian model agrees to about 0.003 across that range. A second probe at detail 6 / roughness 0.55 / scale 12 gave the same span. **Consequence found this way:** `_painted_metal` in this project thresholded its chip mask at `1 - chip_amount` = 0.84..0.92, so no paint chip had ever appeared on any prop, and because the same ramp drives Metallic, every painted surface was a pure dielectric.","engine":"5.0.1","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"B136","fact":"**MifBlender's ping answers in under a tenth of a second NORMALLY and occasionally takes nearly four, so a 4-second liveness check is flaky by construction.** `tools/mifblender.py::up()` was a single `call(\"ping\", timeout=4)`; measured against a Blender that was demonstrably alive, it returned True in **0.10 s**, True in **0.08 s**, then **False at 4.01 s**, then True in **3.79 s** when given a longer timeout. The slow answers land right on the old limit.","source":"measured 2026-09-19, five consecutive CLI pings all succeeding either side of the failures. **It cost three separate incidents in one day**, each surfacing as `nothing answering on 8802` from `bake_library.py` while Blender was fine, and one of them wasted a measurement: a normal-map fix was judged against stale textures and declared not to work, when the bake had never run. `up()` now retries up to four times with a growing timeout. **A liveness check that can report a live service as dead is worse than no check**, because everything downstream trusts it.","engine":"","ue":[],"bl":[],"section":"UE materials, actor identity and PIE"},{"id":"B141","fact":"**`run_python` reports as DISABLED when the add-on is loaded from a path insert rather than installed as an add-on**, whatever the preference default says. The gate reads `bpy.context.preferences.addons['MifBlender'].preferences.allow_run_python`; a server started with `sys.path.insert` + `MifBlender.start_server()` has no registered add-on entry, so the lookup yields None, `getattr(None, 'allow_run_python', False)` is False, and the op refuses. The default in `__init__.py` is `True` and is not what decides it.","source":"measured 2026-09-19 on Blender 5.0.1 started `--factory-startup` with `--python-expr` doing a path insert and `start_server(port=8797)`. `run_python{code:'result = 1'}` refuses with *\"run_python is disabled. Enable it in Edit > Preferences > Add-ons > MifBlender > 'Allow run_python'\"*. Source: `tools/blender-addon/MifBlender/ops_scene.py` line 578 against `__init__.py` line 182. Consequence: a headless showcase Blender cannot use the escape hatch, so measurements there must go through named ops - which is how `objects_overlap` and `closest_point_on_mesh` were found in the first place.","engine":"5.0","ue":[],"bl":["5.0"],"section":"UE materials, actor identity and PIE"},{"id":"U723","fact":"**A headless (background) MifBlender serves ONE client connection at a time: its accept loop runs `_handle_client(client)` inline when `bpy.app.background` is set (a GUI Blender hands each client to a thread), so a second caller waits in the listening socket's backlog until the first call ends, and runs then.** The consequence measured on 2026-11-07: a `bake_texture_set` start whose caller had given up after a 600 s timeout ran about 23 minutes later, after the caller had changed the scene, and baked the changed scene (roughness 0.902 where 0.2 was sent). Every MCP request now carries a deadline the add-on refuses late (`callerGaveUp`), from the character-tools lane's commit 52cd3d995 (not yet on master).","source":"read from disk 2026-11-07: `tools/blender-addon/MifBlender/server.py` lines 947-985 at master 8b61ab4ed (`_server_loop`; the inline call at 977-978, the thread for a GUI Blender at 979-981).","engine":"-","ue":[],"bl":[],"section":"","newer":true},{"id":"B154","fact":"**`bpy.context.window` hands back a FRESH wrapper on every access, so `is` comparison against a window from `bpy.data.window_managers[].windows` is always False.** `bpy_struct` compares by the datablock it wraps, so `==` answers the question that was meant and `is` silently answers a different one.","source":"measured 2026-09-20 on 5.0.1 through MifBlender: `set_current_scene`'s per-window rows used `w is window` and reported `current: false` for the ONLY window in a `-b` session - the very window the op had just moved. Changed to `==` in `ops_scene.py:op_set_current_scene`; `tools/test_blender_scene_switch.py` T911 asserts a current window is named, and it passes on 3.6, 4.2, 4.4, 5.0 and 5.2.","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B155","fact":"**`scene_info` reports a SCENE's name beside FILE-wide counts, and in a multi-scene file they describe different things.** `sceneName`, `frameCurrent` and `inViewLayer` come from `bpy.context.scene`; `objectCount`, `objectsByType`, `meshCount` and `objects` all walk `bpy.data.objects`, which is every object in the .blend. In a single-scene file the two are identical, which is why it went unseen - **nothing could switch the active scene until 2026-09-20, so nothing could ever put them in disagreement.**","source":"measured 2026-09-20 on 5.0.1: with the empty scene `Shot_B` current, `scene_info` answered `sceneName:\"Shot_B\"` beside `objectCount:3` and `meshCount:1` - the other scene's objects. The numbers are UNCHANGED (a file census is legitimate and callers read those fields); what was added is `sceneObjectCount`, `sceneCount` and a warning that fires only when the file has more than one scene AND the counts disagree, naming `sceneObjectCount`, `viewLayerObjectCount` and `inViewLayer` as the scene-scoped answers. `tools/test_blender_scene_switch.py` T913, 4/4 on 3.6, 4.2, 4.4, 5.0 and 5.2.","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B156","fact":"**Six of MifBlender's ten unbounded listing ops grow with a DATABLOCK or the SCENE rather than with the request, and the largest is `list_view_layers` at ~867 bytes a layer.** Per-item response growth, measured through the socket: **list_view_layers withPasses ~867 B/layer** (61 layers = 52,880 B), **list_particles ~800 B/system** (8 = 6,420 B), **list_constraints ~190 B/row** (60 = 11,577 B), **list_view_layers plain ~144 B/layer** (60 = 8,960 B), **list_custom_properties ~132 B/property** (200 = 26,454 B), **list_animation_data ~130 B/driver**, **world_info ~11 B/world**. The other four do NOT grow in the dimensions tried: `uv_info` reports island AGGREGATES only, `object_info` carries no custom-property rows (857 B with 200 of them on the object), `file_info` 1,318 B and `render_info` 903 B are fixed. **`uv_info`'s `maxReportedIslands` is not an output bound** - it decides whether a note is attached and truncates nothing (`ops_mesh.py`, `if len(islands) > max_islands:` sets `islandsTruncatedNote` and nothing else), which is why a vocabulary of bound-shaped NAMES got it wrong in both directions at once.","source":"measured 2026-09-20 on Blender 5.0.1 through MifBlender on 127.0.0.1:8871, response BYTES off the length-prefixed socket, against a scene built by the bridge itself. Re-runnable: `tools/test_blender_listing_bounds.py` builds the same fixture and asserts the bounds (36 assertions, 36/36 on **3.6.23, 4.2.17 LTS, 4.4.0, 5.0.1 and 5.2.1 LTS**); `tools/audit_blender_unbounded_listings.py` now proves a bound by tracing the parameter to a truncation - limit_rows, a slice, a `range()`, or a length guard that controls an append - and prints which mechanism it found. The four with a flat top-level row list were given `limit` on the same day; `list_animation_data` (nested per-source driver and NLA lists) and `world_info` were not.","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B157","fact":"**A shared truncation note advised a parameter 7 of 27 `limit`-taking ops would refuse.** `limit_rows`' note ended *\"...or drop rows you do not want with nameExcludes\"* unconditionally. Measured 2026-09-20: of the 27 Blender ops that accept `limit`, **20 accept `nameExcludes` and 7 do not** - `face_info`, `select_faces` and `vertex_info` (which write their own notes naming their own filters, so they were never wrong) plus the four bounded that day, which would have been. `face_info` answers *\"unknown param(s) nameExcludes\"* if a caller follows it. This is the same defect class as a hint naming an endpoint that does not exist, one level down: **advice is a claim about the accept list, and nothing compares the two.**","source":"measured 2026-09-20 on 5.0.1 by asking `describe_op` for all 190 ops' `acceptedKeys` and intersecting. Fixed by `limit_rows(rows, params, what, excludes=True)` - the default is true because every pre-existing caller does accept it, and the four new ones pass `excludes=False`. `tools/test_blender_listing_bounds.py` T921 asserts the biconditional per op: the note mentions nameExcludes **if and only if** the op accepts it, tested by actually sending it and reading the refusal. 8/8 on all five builds.","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B158","fact":"**MifBlender's `purge_orphans` sweeps the WHOLE FILE, not what the call orphaned - and `clear_scene` defaults it TRUE.** `_delete` walks sixteen `bpy.data` collections in up to eight passes and removes **any** datablock with zero users, regardless of what this call touched. A material between being created and being assigned has zero users; so does a node group before it is instanced. The guidance said the opposite in both wrappers: *\"purge_orphans also frees the datablocks the deletion orphaned\"*.","source":"measured 2026-09-20 on Blender 5.0.1 through MifBlender, with a control. **`clear_scene{type:'LIGHT'}` in a scene with no lights answered `removedCount:0, orphansPurged:2`** and destroyed a material and a node group created moments earlier - a call that by its own report deleted nothing. `delete_object{object:'MifUnrelated', purge:true}` on an object with no connection to the material took it too; the same delete **without** purge left it (`orphansPurged:0`). **The behaviour is deliberate and was not changed**: `ops_scene.py` records that the file-wide pass is what stops Blender's name reservation handing a rebuilt scene `MifT_CURVES.002`, and the multi-pass loop exists because a material held only by a curve keeps its user until the curve goes. What was added is visibility - `orphansPurgedNames` (type:name, capped at 50) and an `orphanPurgeNote` that says FILE-WIDE, plus a second sentence when `removedCount` is 0, because a purge that freed something while removing no object cannot have been freeing what this call orphaned. Both MCP descriptions corrected. `tools/test_blender_purge_scope.py`, 16 assertions, 16/16 on **3.6, 4.2, 4.4, 5.0 and 5.2**.","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B159","fact":"**Five things MifBlender can create have no remover, and two of them are not even reachable by the purge sweep.** Measured by creating each and then trying every remover in the live op table against it: **removable** - every object type via `delete_object` (with `purge` for its data datablock), collections, scenes, view layers; **removable only as a side effect** - materials, node groups and textures, which go when `delete_object{purge:true}` is called on an UNRELATED object and take every other orphan with them; **not reachable at all** - `actions` (in the sweep's list, but an action assigned to an object is not an orphan), `worlds` and `fonts`, neither of which is in the sweep's collection list. So `create_world` and `create_text` add a datablock that lives for the session whatever the caller does. This is the same shape that produced `delete_scene` on 2026-09-14 - *\"every other creatable thing has a remover... and a scene did not\"* - found then by hand, now by a tool. **[Reviewed 2026-09-21 against the code - STALE in part: purge_orphans only/clearFakeUser removes named worlds, fonts, actions (B167); B159 not marked superseded (ops_file.py:40-42, 551-656, 699-715); purge_orphans only removes named worlds/fonts/actions; not marked superseded (ops_file.py:536-548, 551-656, 699-712); The sweep lists worlds and fonts; purge_orphans only/clearFakeUser removes each singly (B167 records it the same day) (ops_file.py:41-42, 551-591; server.py:5422-5425)]**","source":"measured 2026-09-20 on 5.0.1 by `tools/audit_blender_creator_no_remover.py`, which calls each of the 40 `create_*`/`add_*` ops with `blender_version_matrix.PAYLOADS`, reads which `bpy.data` collection grew out of `file_info.counts`, then tries every remover in the table with each of eight name spellings and with `purge`. 15 removable, 3 incidental, 4 unreachable, 4 that add no datablock, 14 not measured because the creator refused for want of a fixture (named individually, not folded into the count). Three narrower rules were wrong first and each is recorded in the file: comparing against the post-create counts rather than the pre-create ones made **22 of 26** read as unremovable; trying plain-then-purge meant `delete_object` succeeded on the plain form and the purge attempt then had no object left to name, so `create_primitive` read as unremovable.","engine":"5.0","ue":[],"bl":["5.0"],"section":"","status":"superseded"},{"id":"B160","fact":"**`links.new` into a Blender input that already holds a link REPLACES the old link and says nothing; the tree's link count does not move.** `V1.Value -> Principled.Roughness` then `V2.Value -> Principled.Roughness` leaves V2's link only, with `linkCountBefore == linkCountAfter == 2` - so a caller comparing counts reads \"nothing changed\" where a wire was swapped. A **multi-input** socket (`is_multi_input`, Join Geometry) keeps both.","source":"measured 2026-09-20 through `link_group_nodes` - first on 5.0.1 in a scratch batch (`h3_l1`/`h3_l2`, the second call's response carried nothing about the displaced link), then as `tools/test_blender_node_inputs.py` N109 (the new `replacedLinks` names V1, and `list_group_nodes` shows V2 alone) and N113 (two Mesh Cube outputs into one Join Geometry input both stay) - 14/14 on every build","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B170","fact":"**Blender stores a NaN written into `Scene.gravity`, an `EditBone`'s head or roll, a vertex-group weight or an object's origin, without complaint - and MifBlender had 21 bare `float()`/`int()` conversions in 13 ops that let one through, or died, or truncated.** Measured through each op against the add-on at 46693af1: NaN STORED under ok:true by `set_origin {mode:point}` (locationBU `[3,0,0]` became `[nan, nan, nan]`), `set_physics_world gravity` (`[0, 0, -9.81]` became `[0, 0, nan]` through `physics_info`), `add_bones` head and roll (both bones in `list_bones`), `create_armature` head, `set_vertex_weights` weights; a RAW exception with a traceback from a string in `set_origin` / `add_bones` / `create_armature` (the last AFTER making the armature, which stayed) and `audit_placement` bounds / floor, from NaN or Infinity in `create_lattice` resolution and `face_info` limit / slot, from Infinity in `list_keyframes maxKeyframes`, `list_group_nodes maxLinks`, `set_render_settings colorDepth`, `select_faces slot`, `set_shading indices`; a fraction TRUNCATED - `create_lattice [2.7, 2, 2]` made a 2x2x2 lattice, `face_info indices [2.5]` answered with face 2, `set_shading indices [2.5]` smoothed face 2, `maxKeyframes 2.5` returned 2 keys per curve. The old audit matched variable NAMES; its new provenance pass (taint from `params`) finds exactly these 21 on the add-on at 46693af1 and 0 after.","source":"measured 2026-09-20/21 on 5.0.1 (scratch `nan_probe.py`, `trunc_probe.py`), then `tools/test_blender_finite_inputs.py` F100-F112 - **13/13 on 3.6, 4.2, 4.4, 5.0, 5.2; 1/13 against the add-on at 46693af1 on each of the five, the same nan read back on every build**. 42 other suites calling these ops (fresh Blender each) clean on all five; `blender_version_matrix` on the 13 ops: no raw exception, no divergence, no value drift","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B171","fact":"**MifBlender read a caller's flag with a bare `bool()` in six ops and the `batch` envelope, so `\"false\"` meant TRUE - and three of those destroyed or moved work.** Against the add-on at e077d7fc: `boolean_op {deleteCutter: \"false\"}` deleted the cutter (`cutterDeleted: true`; the typo `\"flase\"` too); `create_armature` / `add_bones {connect: \"false\"}` connected the bone, a child asked for at `[1, 0, 0]` reading back at `[0, 0, 1]` through `list_bones`; `rename_bones {dryRun: \"false\"}` renamed nothing (`dryRun: true`); `bake_texture {keepNode: \"false\"}` left a `ShaderNodeTexImage` in the material (`targetNodeKept: true`); `batch {continueOnError: \"false\"}` ran the op after a failure (`continueOnError: true`, the object made), `{stopOnBudget: \"false\"}` stopped at 1 of 3 ops, `budgetSeconds: NaN` ran with the budget off and echoed nan. The shared `limit_rows` returned a RAW `OverflowError` for `limit: Infinity` and 2 rows for 2.5 (`list_objects`, `list_materials`); `add_group_node location [NaN, 0]` stored a node at x = nan, `[\"abc\", 0]` was a raw `ValueError`, `[Infinity, 0]` became 1000000.0. `create_armature {showInFront: \"flase\"}` was refused with the armature object and data already in the file.","source":"measured 2026-09-21 on 5.0.1 (scratch `bool_probe.py`, `seed_probe.py`, `debris_probe.py`), then `tools/test_blender_strict_flags.py` S100-S111 - **12/12 on 3.6, 4.2, 4.4, 5.0, 5.2; 1/12 against the add-on at e077d7fc on each of the five**; the provenance `bool()` rule (in `audit_blender_raw_bool` since 3ac592ab): 8 findings on e077d7fc, 0 after; `audit_unguarded_numbers`' `params` seed: 4 number findings on e077d7fc, 0 after","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B173","fact":"**MifBlender's two integer readers disagreed: `finite_int` refused 2.5, `take_int` (about sixty parameters) FLOORED it.** Against the add-on at c3ff10d9 on 5.0.1: `set_render_settings {resolutionX: 1920.7}`, `set_frame_range {start: 2.5}` and `create_primitive {segments: 12.9}` refused *\"must be a whole number ... refused, not rounded or truncated\"*, while `bake_texture {width: 8.5}` returned `width: 8`, `decimate_mesh {targetTris: 100.5, dryRun: true}` returned `targetTris: 100`, `face_info {limit: 2.5, withFaces: true}` returned 2 rows, and `undo {steps: 1.5}` returned `steps: 1, askedSteps: 1`.","source":"measured 2026-09-21 (scratch `takeint_probe.py`, `takeint_probe2.py`), then `tools/test_blender_finite_inputs.py` F113 - fails against c3ff10d9 on 3.6, 4.2, 4.4, 5.0, 5.2; after the fix 14/14 on all five","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B176","fact":"**Seven more MifBlender ops made something and then refused a value they read after it.** Against the add-on at 774bdf1e, read back through other ops: `set_marker {name, frame: 50, rename: 5}` refused *\"'rename' must be str\"* with the marker created (`list_markers`), and `{frame: 12.5}` returned ok (floored); `add_driver {expression: 5}` left `driverCount: 1` (`list_animation_data`); `add_nla_strip {pushDownActive: true, start: NaN}` refused with `actionName` `MifLeftBakeAction` -> **null** and `nlaTrackCount` 0 -> **2**; `add_constraint {constraintName: 5}` left 1 constraint; `set_vertex_color {makeActive: \"flase\"}` and `{faces: [999]}` left both colour attributes (`mesh_stats`); `create_view_layer {use: \"flase\"}` left the layer (`list_view_layers`); `set_world {color, strength: NaN}` wrote the colour (`world_info`).","source":"measured 2026-09-21 by `tools/test_blender_refusal_leaves_nothing.py` D117-D124 against 774bdf1e - every row fails on 3.6, 4.2, 4.4, 5.0, 5.2; after the fix 25/25 on all five. Found by `audit_mutate_then_deny`'s widened DEBRIS rule: 16 findings in these 7 functions on 774bdf1e, 0 after","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B177","fact":"**Blender 5.0 changed the value a NEW shape key starts at: `shape_key_add` gives 0.0 on 3.6.23, 4.2.17 and 4.4.0 and 1.0 on 5.0.1 and 5.2.1 - the Basis included.** Through MifBlender (`add_shape_key` twice on a cube, then `set_point_positions {shapeKey: Dent, points: [[0, 0, 0, 3]]}`): on 4.4 `deforms[0].changed` false and the note *\"at value 0, so its new positions show only once set_shape_key gives it influence\"*; on 5.0 `changed` true, `evaluatedChanged: [\"SK\"]`, shape fingerprint c6aa6a730aeda578 -> 851d7f7f17b660ff.","source":"measured 2026-09-21 (scratch `shapekey_probe.py`, `shapekey_probe2.py`), then `tools/test_blender_shape_key_default.py` - V101/V102 fail against f36757b1 on 5.0 and 5.2, 4/4 on all five after","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B178","fact":"**`bl_bevel_edges`' MCP defaults flattened an ordinary bevel: the tile guard it sent, preserveAxes/assertAxes [\"X\"], snaps every vertex within the offset of the X extremes back onto them.** Through MifBlender `bevel_edges {allEdges, offsetUU: 15, segments: 3, profile: 0.5, preserveAxes: [\"X\"], assertAxes: [\"X\"]}` on a 2 m cube: ok:true, 96 vertices, `seamPlanarity.X.onSeamAfter` **96** and `offSeamVerts.X` **0** - against 8 and 88 for the same bevel without the guards; a 0.2 m cube the same. The tile selector the wrapper sent with no selector (boundaryOnly, axis Y, side both) on a closed cube: refused, \"matched 0 edges\". No offset: refused naming `offset` and `offsetUU` - and the wrapper could not send `offset`. **[Reviewed 2026-09-21 against the code - STALE in part: Fixed: bl_bevel_edges requires a selector, defaults preserve_x=False, sends offset. Only mif_mesh_roundtrip keeps tile defaults (server.py:5489-5495, 5514-5519, 5524, 5542-5543)]**","source":"measured 2026-09-21 (scratch `measure_bevel_defaults.py`, add-on at cb5dbed7), then `tools/test_payload_contract.py` P105 for the wrapper's payload","engine":"5.0","ue":[],"bl":["5.0"],"section":"","status":"superseded"},{"id":"B179","fact":"**Ten MifBlender setters wrote part of a call and then refused a value read after it - the change stood, and the refusal came from a shared reader that said nothing about it.** Against the add-on at cb5dbed7 on 5.0.1, each read back through a different op: `set_action {fakeUser: true, rename: 5}` useFakeUser false -> **true** (list_actions); `set_nla_track {mute, solo, rename: 5}` mute and isSolo false -> **true** (list_animation_data); `set_uv_layer {rename, active: \"flase\"}` layer UVMap -> **MifLeftUV** (uv_info); `set_color_management {exposure: 1.25, useCurveMapping: \"flase\"}` / `{gamma: 1.7, sequencerColorspace: <unknown>}` exposure 0 -> **1.25**, gamma 1 -> **1.7** (render_info); `set_shape_key {value: 0.7, sliderMax: 2, mute: \"flase\"}` value 0 -> **0.7**, sliderMax 1 -> **2** (list_shape_keys); `set_view_layer {passes: {z, mist}, use: \"flase\"}` and `{passes: {mist: \"false\"}}` - the second ACCEPTED, bool(\"false\") being true - enabled passes gained **mist and z** (list_view_layers); `set_viewport_shading {shading, showOverlays: \"flase\"}` SOLID -> **WIREFRAME**; `render_still {resolutionX: 66, resolutionY: 50, writeStill: \"flase\"}` left the scene at **66x50** (render_info); set_camera, set_render_settings and a transform+look set_color_management (the look lost) failed their rows too.","source":"measured 2026-09-21: `tools/test_blender_refusal_leaves_nothing.py` D125-D134 + D130b with the add-on loaded from a worktree of 227b2028 (add-on cb5dbed7) - 11 of 11 fail; 36/36 with the fix","engine":"5.0","ue":[],"bl":["5.0"],"section":""},{"id":"B180","fact":"**Two Blenders writing into one folder at once made MifBlender's output-folder check refuse a writable folder, because the probe file it writes had a fixed name.** Against the add-on at d0fc6f33: Blender 5.0.1 and 4.4.0, each exporting 60 OBJs (`export_scene`) into the same writable folder from its own thread - **9 of 120 calls refused** *\"the output directory ... cannot be written\"* with WinError 5 (access denied), 2 (not found) and 32 (in use) on `.mif_export_probe`, 111 files written; a second run, 3 + 6 = 9 again. A folder holding a DIRECTORY named `.mif_render_probe` made `render_animation` refuse *\"[Errno 13] Permission denied\"*. After `check_writable_dir` (a `tempfile.TemporaryFile`): **0 of 120, twice**, 120 files, no `.mif*` left; the colliding render wrote `f_0001.png`, exit 0.","source":"measured 2026-09-21 (scratch `probe_race.py` on ports 8870/8871, `render_probe_collision.py`), then `tools/test_blender_output_probe.py` O101-O102 fail against d0fc6f33, 5/5 after","engine":"4.4, 5.0","ue":[],"bl":["4.4","5.0"],"section":""},{"id":"B181","fact":"**After add_modifier and set_modifier gained the generic RNA write path (16eabc6e), a setting written through it could be read back by no MifBlender op.** Against the add-on at f4b8c4cc on 5.0.1: `add_modifier` BEVEL `{limit_method: ANGLE, angle_limit: 0.5}`, SUBSURF `{quality: 5, use_limit_surface: false}`, WAVE `{height: 0.7, width: 2.0}` - each ok with the names in `settingsApplied`; `list_modifiers` settings for BEVEL `[segments, width]`, SUBSURF `[levels, renderLevels]`, WAVE **none** (WAVE has no curated read fields). The add's own `readBack` was the same curated row. With `list_modifiers {rna: true}` (every readable RNA property): BEVEL 37 properties on 5.0/5.2 and 33 on 3.6, WAVE 38/36, BOOLEAN 23, NODES 24/13 - limit_method ANGLE, angle_limit 0.5, height 0.7 and a BOOLEAN's object read back on 3.6, 5.0 and 5.2.","source":"measured 2026-09-21 (scratch `modread_probe.py`, `rna_dump_probe.py` on 3.6.23, 5.0.1, 5.2.1), then `tools/test_blender_set_modifier.py` S118-S120 fail against f4b8c4cc, 22/22 after","engine":"3.6, 5.0, 5.2","ue":[],"bl":["3.6","5.0","5.2"],"section":""},{"id":"B183","fact":"**The MifBlender call journal: a start record is on disk before the op runs, a killed Blender leaves that start with no end, and the file costs ~21 us a call.** `atexit` runs at a normal background exit on 3.6.23 and 5.2.1; a GUI File > Quit (`wm.quit_blender`, 5.0.1, exit 0) unregisters the add-on first, so the shutdown record of a GUI quit comes from unregister. Through `server._execute` on 5.0.1, `list_objects {pattern, limit}` (11.0 us bare) took **75.3 us with the file on and 54.0 us with it off** (3 rounds x 2,000); 12,015 records were 1,553,640 bytes (129 B a record, ~260 B a call). At session open a 17,882,440-byte file was renamed journal.1.jsonl and the new file began with the session header (4.4.0). A Blender killed inside run_python (`os._exit(3)`) left its run_python start as its last record; a second Blender appended with the first one's bytes unchanged and `call_log {disk: true}` named the killed call unfinished.","source":"measured 2026-09-21 (scratch `journal/bl_facts.py`, `gui_check.py`, `cost_check.py`, `rotate_check.py`), then `tools/test_blender_call_journal.py` J100-J115 0/16 against ed902884 and 16/16 after","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B184","fact":"**decimate_mesh reported values Blender had clamped, ran with parameters its mode does not read, and emptied a mesh without a note.** Against the add-on at dd2affdf on 5.0.1, a fresh 32x16 UV sphere (960 tris; 482 verts, 992 edges, 512 faces) per case: DISSOLVE `angleLimit` 400 and 180 - ok, **2 verts, 1 edge, 0 faces**, no note, 400/180 echoed; -5 - nothing removed, the note blaming coplanarity; UNSUBDIV `iterations` -3 - nothing removed; 40000 - 3 verts, 3 edges, 1 face (Blender ran 32767), 40000 echoed; COLLAPSE ratio 0.5 + angleLimit 30 or + iterations 3, DISSOLVE 10 + iterations 3, UNSUBDIV 1 + angleLimit 10 - each ok, the unread value echoed. DISSOLVE 12 took 960 to 494 tris. The Decimate modifier's RNA ranges are angle_limit [0, pi] and iterations [0, 32767], identical on 3.6.23 and 5.2.1. After the fix every out-of-range or mode-foreign call is refused with the sphere still 482/992/512, and a 180-degree DISSOLVE answers emptied:true with a note.","source":"measured 2026-09-21 (scratch `decimate_probe.py`, `decimate_probe2.py`, `decimate_probe3.py`), then `tools/test_blender_decimate_guard.py` DM100-DM107 2/8 against dd2affdf and 8/8 after","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B185","fact":"**Blender keeps exactly `preferences.edit.undo_steps` undo steps back, and MifBlender's undo_history promised more.** Plain bpy, --background, `ed.undo_push` then `ed.undo` until its poll fails: a base step and 40 pushes with undo_steps 32 - **32 undos** succeed (8 of 40 cubes left); 25 pushes with undo_steps 10 - **10**; 20 pushes with 32 - all **20**; identical on 3.6.23 and 5.0.1. Through the add-on at ed902884/dd2affdf, after test_blender_reject_unknown in the same Blender: undo_history `undoAvailable: 124`, `undo {steps: 64}` took back 31 and stopped on \"poll() failed\"; after 40 transform_object calls past a 32-step limit, undoAvailable 42 and 32 undone, then the poll failure. After the fix: undoAvailable 32, 32 undone, stopped at the oldest step Blender keeps.","source":"measured 2026-09-21 (scratch `journal/undo_limit_probe.py`), then `tools/test_blender_undo.py` U110 failing against dd2affdf and 12/12 after","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B186","fact":"**No MifBlender reader reported an object's parent or children, and Blender's Object.children costs a scan of every object.** Against the add-on at 8f4963b2 on 5.0.1: after set_parent {type: BONE, bone: hand} (whose own reply said parentAfter rig/BONE/hand), object_info, list_objects {detail} and scene_info {detail} carried no parent and no children key for any object. `Object.children` is a Python property in bpy_types: at 5,000 objects one read took **0.55 ms on 5.0.1 and 0.36 ms on 3.6.23**, so reading it per object in a detail listing is quadratic; one pass building a map keeps list_objects {detail} on 5,004 objects at 84 ms (68 ms without the fields) and scene_info {detail} at 95 ms (79).","source":"measured 2026-09-21 (scratch `clamp/children_cost.py`, `clamp/detail_cost.py`), then `tools/test_blender_hierarchy_readback.py` H100-H106 1/7 against 8f4963b2 and 7/7 after","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B187","fact":"**A light's linking collections were reported only by set_light_linking's own reply.** Against the add-on at 8f4963b2 on 5.0.1: after set_light_linking {receiverCollection, blockerCollection} answered both names, object_info's `light` record and list_lights' row carried no lightLinking key at all. Blender 3.6.23 has no `Object.light_linking` (set_light_linking refuses there by name); 4.2.17, 4.4.0, 5.0.1 and 5.2.1 do.","source":"measured 2026-09-21, `tools/test_blender_light_linking_readback.py` LK100-LK104 1/5 against 8f4963b2 and 5/5 after on 4.2-5.2","engine":"4.2, 4.4, 5.0, 5.2","ue":[],"bl":["4.2","4.4","5.0","5.2"],"section":""},{"id":"B260","fact":"**Blender's light linking arrived in 4.0: `Object.light_linking` (an ObjectLightLinking, read-only, never None) is in the 4.0 Python API reference and absent from 3.6's, so set_light_linking refuses by name on 3.6 and works from 4.0 on.** The add-on's refusal, its docstring and the MCP help said \"arrived in 4.2\" until 58a157b3, because the builds measured for B187 skip from 3.6.23 to 4.2.17. test_blender_light_linking_readback now asserts the refusal on 3.6 (LK105-LK107) instead of skipping it.","source":"read 2026-09-28: Blender's Python API reference, page bpy.types.Object, for 4.0 (docs.blender.org/api/4.0 - light_linking listed) and for 3.6 (docs.blender.org/api/3.6 - absent); the certification run the same evening at add-on 58a157b3: LK105-LK107 green on 3.6.23, the readback suite green on 4.2.17, 4.4.0, 5.0.1 and 5.2.1","engine":"4.0-5.2","ue":[],"bl":["4.0","5.2"],"section":""},{"id":"B188","fact":"**Windows reuses Blender process ids within minutes, and a journal reader keyed on the pid alone calls a dead Blender restarted.** The five-build certification at d0c01ebd wrote 595 session headers from **516 distinct pids** into one scratch journal (70,791 records, 0 unreadable, 0 calls without an end). Against the add-on at 1ca58186, a journal with a session, a start with no end, then a later header with the same pid and no shutdown read `restarted: true` and no death; a real restart (shutdown kind restart between two headers) read no `restarted`; and one Blender's two headers with no shutdown between read as two sessions, the first restarted. After the fix: died with evidence, restarted, and one session (reopened 1) respectively.","source":"measured 2026-09-21 (scratch reading of the suites journal), then `tools/test_blender_call_journal.py` J118-J121 failing against 1ca58186 and 22/22 after on all five builds","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B189","fact":"**Eight settings the bridge writes were readable only in the writing call's own reply.** Measured 2026-09-21 on 3.6.23, 4.2.17 and 5.0.1 against 1ca58186, writer then every plausible reader: set_shading autoSmoothAngle 35 - 3.6 wrote mesh.use_auto_smooth/auto_smooth_angle, read by nothing; 4.2 shade_auto_smooth CANCELLED headless, 12 edges marked sharp, counted by nothing; 5.0 a Smooth by Angle modifier, whose Angle input (0.610865 rad) list_modifiers already read. keepSharp false - already in list_modifiers {rna: true} as keep_sharp on all three. create_curve cyclic/usePath/handle types, create_lattice KEY_LINEAR/useOutside, create_armature STICK/showInFront: object_info carried none (no lattice record). set_vertex_color active Base / render Mask: mesh_stats listed [Base, Mask] only. add_particles childCount 7: list_particles had childCount 7, no child type, no render count. set_viewport_shading colorType RANDOM: no reply carried it - and a `-b` Blender launched with --python-expr answered that op ok on all three builds. All APIs involved exist on 3.6.23, 4.2.17 and 5.2.1 (MeshEdge.use_edge_sharp included).","source":"measured 2026-09-21 (scratch `wo/wo_probe.py`, `wo/api_probe.py`), then `tools/test_blender_write_only_readback.py` WO100-WO109 and test_blender_viewport_shading_params B1836 failing against 1ca58186, 10/10 and 8/8 after on all five builds","engine":"3.6, 4.2, 4.4, 5.0, 5.2","ue":[],"bl":["3.6","4.2","4.4","5.0","5.2"],"section":""},{"id":"B190","fact":"**mifwatch --blender, the crash reader, had B188's misreading as well: one Blender opening its journal again - a second header with the same `proc` and no shutdown between - read as two sessions, the first DIED with its in-flight call named as never returned.** Against e3f67821, a hand-built journal: pid 303's reopen gave two sessions, the first `state: died`, `unfinished: ['object_info (seq 2)']` although that call's `end` followed the second header; shutdown `kind` was never read. After the change: one session, 2 calls, `reopened: 1`, `kind: exit`; a different proc on the same pid still reads DIED naming its `run_python` start; the report prints `(exit)` and `(restart)` and exits 2 for the death. journal.py writes a header on EVERY open (`_ensure_open` -> `_session_record()`) and a shutdown only when `_close` is given a reason, which is why a write error produces the second header.","source":"measured 2026-09-21, `tools/test_mifwatch_blender.py` W107 (three checks) failing 3/3 against e3f67821's mifwatch.py and 11/11 after; journal.py `_ensure_open` / `_close` read in the working tree the same day","engine":"offline (no Blender)","ue":[],"bl":[],"section":""},{"id":"B198","fact":"**A fluid FLOW's `surface_distance` is in grid CELLS, not metres: at 0.08 a 0.1 m burner emitted nothing and the domain rendered empty; Blender's default is 1.5.**","source":"measured 2026-09-21 on 4.4.0: scratchpad probe_fire4.py with surfaceDistance 0.08 - no fire at frame 120 in EEVEE tile 8, tile 2 or Cycles, with 120 data and noise frames on disk - against the same scene with the default (probe_fire4c): fire at frame 120; the default read back through `list_modifiers {rna}` as flow_settings.surface_distance 1.5 (probe_fire5.py)","engine":"4.4","ue":[],"bl":["4.4"],"section":""},{"id":"B204","fact":"**`bpy.ops.fluid.free_all()` frees nothing by the time it returns: its exec only STARTS a window-manager job, which a headless Blender never runs before the next call (and a GUI one runs later, against whatever is then writing the folder). Writing a domain's `time_scale` onto itself frees synchronously: its update is Blender's own `rna_Fluid_datacache_reset` -> `BKE_fluid_cache_free`.**","source":"read in Blender's source: editors/physics/physics_fluid.cc `fluid_free_exec` (WM_jobs_start) at v4.4.0; `time_scale`'s update `rna_Fluid_datacache_reset` in rna_fluid.c v3.6.0 and rna_fluid.cc v4.4.0 / v5.2.0. Measured 2026-09-21 on 4.4.0: after bake_physics {rebake} using fluid.free_all a domain's frames survived and were reported kept; a same-value time_scale write through set_modifier took a baked domain's data folder from 6 files to 1 (the current frame, re-evaluated) (scratchpad probe_free.py)","engine":"3.6-5.2 (source); 4.4 (measured)","ue":[],"bl":["3.6","4.4","5.2"],"section":""}]