TL;DR: Importing a stock Blizzard .m3 and re-exporting it silently degrades its bounding data in two independent ways: every per-sequence SEQS.bounding_sphere is written as all zeros (the exporter never assigns the field), and the model-level MODL.boundings.radius is dropped on import and recomputed on export as half the bounding-box diagonal, which inflates it by up to √3 on the (large) class of stock models that store a max-half-extent radius instead. Both reproduce headlessly on Blender 4.5.4 LTS at 52e9b92. A small two-commit fix is proposed in #14; alternatives are listed at the end since the right target value for the sequence spheres is a design call.
Symptom
Round-tripping Carrier_Taldarim.m3 (liberty.sc2mod, base.sc2assets/Assets/Units/Protoss/Carrier_Taldarim/; the standard Carrier.m3 carries identical bounds) through import → export, with no edits in between:
| Field |
Stock value |
After round-trip |
SEQS.bounding_sphere (all 9 sequences) |
min(-2.60467, -2.51439, -2.60467) max(2.60467, 2.69495, 2.60467) radius 2.60467 |
min(0,0,0) max(0,0,0) radius 0 |
MODL.boundings min/max |
as above |
unchanged (correct) |
MODL.boundings.radius |
2.60467 |
4.51142 |
The radius inflation is exactly the half-extent → half-diagonal substitution: the model box extents are 5.20934 on all three axes, and 5.20934 × √3 / 2 = 4.51142.
Reproduction
Blender 4.5.4 LTS, addon at 52e9b92, headless:
blender --factory-startup --background --python roundtrip.py -- Carrier_Taldarim.m3 out.m3
where roundtrip.py enables the addon, runs bpy.ops.m3.import (defaults, new object), selects the resulting armature, and runs bpy.ops.m3.export (defaults). One headless-only caveat: bl_graphics_draw.py calls gpu.shader.from_builtin(...) at module import time, which raises SystemError in --background; stubbing gpu.shader.from_builtin before enabling the addon works around it (the viewport draw handler never runs headless). In a GUI session the same round trip via the UI reproduces identically.
Then compare SEQS/MODL BNDS values of input vs output with any independent M3 parser.
Root cause
1. SEQS.bounding_sphere is never assigned
bounding_sphere is declared in structures.xml#L660 — and that declaration is the only occurrence of the string in the repository. No importer, exporter, or Blender property reads or writes it (checked for dynamic access too: the export processor only touches the fields listed in io_shared.py#L32-L38).
On export, create_sequences (io_m3_export.py#L1163) assigns only the name, the io_anim_group fields, and anim_ms_start/anim_ms_end (io_m3_export.py#L1177-L1185). The record comes from content_add() (io_m3.py#L740-L743), which default-initialises every field (M3StructureData.__init__ → field.default_set, io_m3.py#L319-L326); for the embedded BNDS struct that recurses into float fields whose default is 0.0 (io_m3.py#L467-L470). So every sequence in every exported model gets an all-zero bounding sphere — unconditionally, not as a result of any user choice.
2. MODL.boundings.radius is dropped on import and re-derived differently on export
Import (create_bounding, io_m3_import.py#L1242-L1246) reads only min/max into ob.m3_bounds; BoundingProperties (m3_object_armature.py#L122-L129) has the six box faces and no radius, so the stored radius is discarded. Export then recomputes radius = (max - min).length / 2 (to_m3_bnds, io_m3_export.py#L114-L120) — half the box diagonal. Some stock models do store exactly that; a larger class stores something smaller (see below), and for those no recomputation from min/max recovers the original value.
What Blizzard's own files contain
Surveying 13 varied stock models from liberty.sc2mod (Carrier, Carrier_Taldarim, Marine, Battlecruiser, SiegeTank, Zealot, Colossus, Zergling, Ultralisk, Mutalisk, Overlord, UrsadakFemale, SnowBeast; 176 sequences total):
- No stock sequence or model has a zero bounding sphere anywhere.
- In 10 of 13 models, every sequence's BNDS is byte-identical to
MODL.boundings.
- In the other 3 (Marine, UrsadakFemale, SnowBeast), sequences carry genuinely per-sequence values — animated-bounds unions that differ per animation — and
MODL.boundings min/max is their union.
- The stored radius follows two distinct conventions. The 3 per-sequence models store exactly the half-diagonal — the same formula
to_m3_bnds uses (Marine: box extents 3.27367 × 2.84584 × 4.05959, radius 2.97052 = half-diagonal; its per-sequence radii are likewise the half-diagonals of their own boxes). The other 10 store the maximum half-extent instead, with a box that is (near-)cubic — a sphere-first convention where the box is the sphere's bounding cube (Carrier: extents 5.20934 on all axes, radius 2.60467 = half-extent, while the half-diagonal is 4.51142). So recomputing the half-diagonal happens to reproduce the first class but inflates the second by up to √3, and no single formula from min/max covers both — only carrying the imported value through does.
Why it matters
This is round-trip data loss: a model that merely passes through the addon comes out with different (degraded) data than it went in with, which also makes binary before/after comparisons noisy for unrelated edits.
In-game impact, honestly scoped:
- The model-level radius is consumed by the engine: the CModel field Art: Visual Radius is documented as "If set to 0 or greater, this overrides the radius of the model's bounding sphere. The model becomes visible when its radius touches the edge of the Fog of War…" (SC2Mapster wiki, Data/Models/Generic) — i.e. unless a data override is set, the stored radius drives fog-of-war visibility, and a ~√3-inflated radius means models render sooner/appear inside fog. The predecessor addon likewise exposes exactly this field as its "visibility test" (below).
- What the engine does with the per-sequence spheres specifically I have not established; I did not observe a concrete in-game symptom from the zeros, and I'd rather not speculate here. The report's claim is fidelity, not observed breakage.
Prior art: m3addon
The predecessor addon (SC2Mapster/m3addon, at SC2Mapster/m3addon@5893500) handles both fields: it writes a per-sequence sphere on every export (hardcoded radius 2.0 — m3export.py:1289, m3Sequence.boundingSphere = self.createAlmostEmptyBoundingsWithRadius(2)), and round-trips the model radius through a dedicated scene property (m3import.py:782 m3_visibility_test.radius = self.model.boundings.radius → m3export.py:541 model.boundings.radius = self.scene.m3_visibility_test.radius). So models passing through m3addon are never radius-0, and the model radius survives intact.
Proposed fix
Two commits, kept separable (#14):
- Round-trip the model radius: add a
radius float property to BoundingProperties, read it in create_bounding, and write it back on export — keeping the computed half-diagonal as the fallback when the stored radius is 0, so scenes authored from scratch (and every existing user scene, where the property deserialises to 0) export byte-identically to today.
- Populate
SEQS.bounding_sphere: assign model.boundings to each sequence in create_sequences. This reproduces Blizzard's layout exactly for the 10-of-13 class of models above, and is a conservative over-approximation for models with genuinely per-sequence bounds.
Verified on Blender 4.5.4 LTS: with both changes, round-tripping the Carrier fixture yields all 9 sequence spheres and the model sphere byte-equal to stock (radius 2.60467 in and out); Marine/Battlecruiser/SiegeTank/Zergling round-trips preserve the model BNDS exactly; and a from-scratch scene (no import) exports byte-identical output before vs after the change.
Alternatives considered, in case you'd rather go another way:
- Full per-sequence round-trip — per-sequence min/max/radius properties (+ optional UI) on
GroupProperties; faithful for the Marine-class models, but new surface, and from-scratch sequences still need a default.
- Compute per-sequence bounds — reuse the animated-bounds data
finalize_anim_data already builds for MSEC/SDMB; most correct, but that path is conditional and heavier.
- m3addon parity (hardcoded 2.0) — only defensible as "better than 0".
Aside, noticed while reading but left untouched: SDMB animated-bounds import is a stub (m3_key_collect_bnds is pass, io_m3_import.py#L258-L259) — harmless today since export recomputes them, just related to this area.
TL;DR: Importing a stock Blizzard
.m3and re-exporting it silently degrades its bounding data in two independent ways: every per-sequenceSEQS.bounding_sphereis written as all zeros (the exporter never assigns the field), and the model-levelMODL.boundings.radiusis dropped on import and recomputed on export as half the bounding-box diagonal, which inflates it by up to √3 on the (large) class of stock models that store a max-half-extent radius instead. Both reproduce headlessly on Blender 4.5.4 LTS at 52e9b92. A small two-commit fix is proposed in #14; alternatives are listed at the end since the right target value for the sequence spheres is a design call.Symptom
Round-tripping
Carrier_Taldarim.m3(liberty.sc2mod,base.sc2assets/Assets/Units/Protoss/Carrier_Taldarim/; the standardCarrier.m3carries identical bounds) through import → export, with no edits in between:SEQS.bounding_sphere(all 9 sequences)MODL.boundingsmin/maxMODL.boundings.radiusThe radius inflation is exactly the half-extent → half-diagonal substitution: the model box extents are 5.20934 on all three axes, and 5.20934 × √3 / 2 = 4.51142.
Reproduction
Blender 4.5.4 LTS, addon at 52e9b92, headless:
where
roundtrip.pyenables the addon, runsbpy.ops.m3.import(defaults, new object), selects the resulting armature, and runsbpy.ops.m3.export(defaults). One headless-only caveat:bl_graphics_draw.pycallsgpu.shader.from_builtin(...)at module import time, which raisesSystemErrorin--background; stubbinggpu.shader.from_builtinbefore enabling the addon works around it (the viewport draw handler never runs headless). In a GUI session the same round trip via the UI reproduces identically.Then compare
SEQS/MODLBNDS values of input vs output with any independent M3 parser.Root cause
1.
SEQS.bounding_sphereis never assignedbounding_sphereis declared in structures.xml#L660 — and that declaration is the only occurrence of the string in the repository. No importer, exporter, or Blender property reads or writes it (checked for dynamic access too: the export processor only touches the fields listed in io_shared.py#L32-L38).On export,
create_sequences(io_m3_export.py#L1163) assigns only the name, theio_anim_groupfields, andanim_ms_start/anim_ms_end(io_m3_export.py#L1177-L1185). The record comes fromcontent_add()(io_m3.py#L740-L743), which default-initialises every field (M3StructureData.__init__→field.default_set, io_m3.py#L319-L326); for the embedded BNDS struct that recurses into float fields whose default is0.0(io_m3.py#L467-L470). So every sequence in every exported model gets an all-zero bounding sphere — unconditionally, not as a result of any user choice.2.
MODL.boundings.radiusis dropped on import and re-derived differently on exportImport (
create_bounding, io_m3_import.py#L1242-L1246) reads only min/max intoob.m3_bounds;BoundingProperties(m3_object_armature.py#L122-L129) has the six box faces and no radius, so the stored radius is discarded. Export then recomputesradius = (max - min).length / 2(to_m3_bnds, io_m3_export.py#L114-L120) — half the box diagonal. Some stock models do store exactly that; a larger class stores something smaller (see below), and for those no recomputation from min/max recovers the original value.What Blizzard's own files contain
Surveying 13 varied stock models from liberty.sc2mod (Carrier, Carrier_Taldarim, Marine, Battlecruiser, SiegeTank, Zealot, Colossus, Zergling, Ultralisk, Mutalisk, Overlord, UrsadakFemale, SnowBeast; 176 sequences total):
MODL.boundings.MODL.boundingsmin/max is their union.to_m3_bndsuses (Marine: box extents 3.27367 × 2.84584 × 4.05959, radius 2.97052 = half-diagonal; its per-sequence radii are likewise the half-diagonals of their own boxes). The other 10 store the maximum half-extent instead, with a box that is (near-)cubic — a sphere-first convention where the box is the sphere's bounding cube (Carrier: extents 5.20934 on all axes, radius 2.60467 = half-extent, while the half-diagonal is 4.51142). So recomputing the half-diagonal happens to reproduce the first class but inflates the second by up to √3, and no single formula from min/max covers both — only carrying the imported value through does.Why it matters
This is round-trip data loss: a model that merely passes through the addon comes out with different (degraded) data than it went in with, which also makes binary before/after comparisons noisy for unrelated edits.
In-game impact, honestly scoped:
Prior art: m3addon
The predecessor addon (SC2Mapster/m3addon, at SC2Mapster/m3addon@5893500) handles both fields: it writes a per-sequence sphere on every export (hardcoded radius 2.0 —
m3export.py:1289,m3Sequence.boundingSphere = self.createAlmostEmptyBoundingsWithRadius(2)), and round-trips the model radius through a dedicated scene property (m3import.py:782m3_visibility_test.radius = self.model.boundings.radius→m3export.py:541model.boundings.radius = self.scene.m3_visibility_test.radius). So models passing through m3addon are never radius-0, and the model radius survives intact.Proposed fix
Two commits, kept separable (#14):
radiusfloat property toBoundingProperties, read it increate_bounding, and write it back on export — keeping the computed half-diagonal as the fallback when the stored radius is 0, so scenes authored from scratch (and every existing user scene, where the property deserialises to 0) export byte-identically to today.SEQS.bounding_sphere: assignmodel.boundingsto each sequence increate_sequences. This reproduces Blizzard's layout exactly for the 10-of-13 class of models above, and is a conservative over-approximation for models with genuinely per-sequence bounds.Verified on Blender 4.5.4 LTS: with both changes, round-tripping the Carrier fixture yields all 9 sequence spheres and the model sphere byte-equal to stock (radius 2.60467 in and out); Marine/Battlecruiser/SiegeTank/Zergling round-trips preserve the model BNDS exactly; and a from-scratch scene (no import) exports byte-identical output before vs after the change.
Alternatives considered, in case you'd rather go another way:
GroupProperties; faithful for the Marine-class models, but new surface, and from-scratch sequences still need a default.finalize_anim_dataalready builds forMSEC/SDMB; most correct, but that path is conditional and heavier.Aside, noticed while reading but left untouched: SDMB animated-bounds import is a stub (
m3_key_collect_bndsispass, io_m3_import.py#L258-L259) — harmless today since export recomputes them, just related to this area.