Skip to content

Exported sequence bounding spheres are always zero; model bounding radius is not preserved on round trip #13

Description

@Talvbot

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.radiusm3export.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):

  1. 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.
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions