Problem
SC2 catalog loading has three issues preventing correct map-local overrides:
-
Duplicate root arrays — sc2_parse_catalogs(), sc2_parse_terrain_data_catalogs(), and sc2_parse_light_data_catalogs() each define their own identical roots[] array (DRY violation).
-
Wrong load order — sc2_parse_catalogs() parses loose/global files ("") before mod dependency roots. The SC2 spec says dependency catalogs load first (Core → Liberty → LibertyMulti → Campaigns), then loose/global, then map-local last. See SC2 Standard Dependencies.
-
No map-local catalog data — sc2_resolve_catalogs() is called after sc2_source_close(), and catalog parsers only read from global game data archives (sc2_read_catalog_xml). Map-local Base.SC2Data/GameData/*.xml files (ModelData, ActorData, UnitData, TerrainTexData, CliffData) are never parsed, so maps cannot override catalog entries.
SC2 Catalog Layering Model
Per Blizzard's design and community documentation (SC2ModKit, SC2Mapster):
- Catalogs are XML files under
Base.SC2Data/GameData/ containing <Catalog> root elements
- Each entry has an
id attribute; later loads override earlier ones for the same id (upsert)
- The
parent attribute enables inheritance chains (up to SC2_MAX_CATALOG_PARENT_DEPTH = 8)
- Load order determines precedence: dependency catalogs → loose/global → map-local (last wins)
- Map-local data lives inside the map's own MPQ archive under
Base.SC2Data/GameData/
The terrain and light data parsers already follow this pattern correctly — the catalog parsers need to match.
Implementation Steps
Step 1: Extract shared sc2_catalog_roots[]
File: games/starcraft-2/common/sc2_map.c
The sc2_catalog_roots[] array already exists as file-static (from merged PR #100). Verify it is used by all three consumers:
sc2_parse_catalogs()
sc2_parse_terrain_data_catalogs()
sc2_parse_light_data_catalogs()
No code change needed if already shared — just verify.
Step 2: Add sc2_read_map_catalog_xml() helper
File: games/starcraft-2/common/sc2_map.c
Add a new static function near sc2_read_catalog_xml() (~line 350):
static xmlDocPtr sc2_read_map_catalog_xml(sc2MapSource_t *source, LPCSTR filename) {
PATHSTR path;
xmlDocPtr doc;
snprintf(path, sizeof(path), "Base.SC2Data/%s", filename);
for (char *p = path; *p; p++) if (*p == '\\') *p = '/';
doc = sc2_read_xml(source, path);
if (doc)
return doc;
snprintf(path, sizeof(path), "Base.SC2Data\\%s", filename);
for (char *p = path; *p; p++) if (*p == '/') *p = '\\';
return sc2_read_xml(source, path);
}
This mirrors how sc2_parse_terrain_data() and sc2_parse_light_data() already read from the map source — try forward-slash, then backslash.
Step 3: Refactor catalog parsers into _doc / _file / _source variants
File: games/starcraft-2/common/sc2_map.c
For each of the 5 catalog types, extract the XML walking logic into a _doc function that takes a pre-parsed xmlDocPtr:
| Current function |
Extract to |
Add new |
sc2_parse_model_catalog_file |
sc2_parse_model_catalog_doc(catalog, doc) |
sc2_parse_model_catalog_source(catalog, source) |
sc2_parse_actor_catalog_file |
sc2_parse_actor_catalog_doc(catalog, doc) |
sc2_parse_actor_catalog_source(catalog, source) |
sc2_parse_unit_catalog_file |
sc2_parse_unit_catalog_doc(catalog, doc) |
sc2_parse_unit_catalog_source(catalog, source) |
sc2_parse_terrain_tex_catalog_file |
sc2_parse_terrain_tex_catalog_doc(catalog, doc) |
sc2_parse_terrain_tex_catalog_source(catalog, source) |
sc2_parse_cliff_catalog_file |
sc2_parse_cliff_catalog_doc(catalog, doc) |
sc2_parse_cliff_catalog_source(catalog, source) |
The _file variant keeps the existing sc2_read_catalog_xml(root, filename) call and delegates to _doc.
The _source variant uses sc2_read_map_catalog_xml(source, filename) and delegates to _doc.
Step 4: Fix sc2_parse_catalogs() load order and add source parameter
File: games/starcraft-2/common/sc2_map.c
Change signature: sc2_parse_catalogs(sc2Catalog_t *catalog) → sc2_parse_catalogs(sc2Catalog_t *catalog, sc2MapSource_t *source)
New load order (last write wins):
1. for each root in sc2_catalog_roots: // dependency catalogs first
parse unit/model/actor/terrain_tex/cliff from root
2. parse unit/model/actor/terrain_tex/cliff from "" // loose/global fallback
3. parse unit/model/actor/terrain_tex/cliff from source // map-local overrides last
Step 5: Fix terrain/light data catalog load order
File: games/starcraft-2/common/sc2_map.c
Both sc2_parse_terrain_data_catalogs() and sc2_parse_light_data_catalogs() should already parse roots[] before "" (verify — this was part of PR #100). If not, reverse the order.
Step 6: Move sc2_resolve_catalogs() before sc2_source_close()
File: games/starcraft-2/common/sc2_map.c
Change: sc2_resolve_catalogs(void) → sc2_resolve_catalogs(sc2MapSource_t *source)
In SC2_MapLoad, move the call from after sc2_source_close(&source) to before it:
sc2_resolve_catalogs(&source); // was after close
sc2_source_close(&source); // must be after resolve
Step 7: Update test fixtures to differentiate Core vs Tiny
Files: games/starcraft-2/tests/resources-src/Mods/Core.SC2Mod/Base.SC2Data/GameData/*.xml
The Core and Tiny fixtures must have different values for overlapping catalog entries so layering bugs are visible:
| File |
Core entry |
Core value |
Tiny value (unchanged) |
ActorData.xml |
MarineActor Model |
MarineCoreModel |
MarineCatalogModel |
ModelData.xml |
new MarineCoreModel |
parent="Unit", Race="Protoss", Occlusion="Hide" |
MarineCatalogModel (Race="Terran") |
UnitData.xml |
Marine Radius |
0.25 |
0.75 |
TerrainTexData.xml |
FixtureGrass Texture |
CoreGrass_Diffuse.dds |
FixtureGrass_Diffuse.dds |
CliffData.xml |
FixtureCliff0 CliffMesh |
CoreCliff0 |
CliffNatural0 |
Step 8: Add assert_tiny_map_catalog_overrides() test helper
File: games/starcraft-2/tests/test_sc2_map.c
Add a helper that asserts the Tiny map's map-local values win over Core:
static void assert_tiny_map_catalog_overrides(sc2Map_t *map) {
// Model resolves through map-local MarineCatalogModel (not Core MarineCoreModel)
ASSERT_STR_EQ(map->objects[1].model, "Assets\\Units\\Terran\\MarineCatalogModel\\MarineCatalogModel.m3");
// Radius is map-local 0.75 (not Core 0.25)
ASSERT_EQ_FLOAT(map->objects[1].radius, 0.75f, 0.001f);
// Terrain texture is map-local FixtureGrass (not Core CoreGrass)
ASSERT_STR_EQ(map->t3Terrain.terrain_textures[0].diffuse, "Assets\\Textures\\Terrain\\FixtureGrass_Diffuse.dds");
ASSERT_STR_EQ(map->t3Terrain.terrain_textures[0].normal, "Assets\\Textures\\Terrain\\FixtureGrass_Diffuse_normal.dds");
// Cliff mesh is map-local CliffNatural0 (not Core CoreCliff0)
ASSERT_STR_EQ(map->t3Terrain.cliff_sets[0].mesh, "CliffNatural0");
}
Call from both:
test_sc2_map_loads_xml_objects_and_terrain (archive path)
test_sc2_map_loads_directory_fixture_without_generated_layers (disk path)
Remove the now-duplicated inline assertions for these fields from both tests.
Validation
All of these must pass before submitting the PR:
make test-sc2 # SC2 catalog + map loading tests
make test # Full test suite
git diff --check # No whitespace errors
References
Related
Problem
SC2 catalog loading has three issues preventing correct map-local overrides:
Duplicate root arrays —
sc2_parse_catalogs(),sc2_parse_terrain_data_catalogs(), andsc2_parse_light_data_catalogs()each define their own identicalroots[]array (DRY violation).Wrong load order —
sc2_parse_catalogs()parses loose/global files ("") before mod dependency roots. The SC2 spec says dependency catalogs load first (Core → Liberty → LibertyMulti → Campaigns), then loose/global, then map-local last. See SC2 Standard Dependencies.No map-local catalog data —
sc2_resolve_catalogs()is called aftersc2_source_close(), and catalog parsers only read from global game data archives (sc2_read_catalog_xml). Map-localBase.SC2Data/GameData/*.xmlfiles (ModelData, ActorData, UnitData, TerrainTexData, CliffData) are never parsed, so maps cannot override catalog entries.SC2 Catalog Layering Model
Per Blizzard's design and community documentation (SC2ModKit, SC2Mapster):
Base.SC2Data/GameData/containing<Catalog>root elementsidattribute; later loads override earlier ones for the sameid(upsert)parentattribute enables inheritance chains (up toSC2_MAX_CATALOG_PARENT_DEPTH= 8)Base.SC2Data/GameData/The terrain and light data parsers already follow this pattern correctly — the catalog parsers need to match.
Implementation Steps
Step 1: Extract shared
sc2_catalog_roots[]File:
games/starcraft-2/common/sc2_map.cThe
sc2_catalog_roots[]array already exists as file-static (from merged PR #100). Verify it is used by all three consumers:sc2_parse_catalogs()sc2_parse_terrain_data_catalogs()sc2_parse_light_data_catalogs()No code change needed if already shared — just verify.
Step 2: Add
sc2_read_map_catalog_xml()helperFile:
games/starcraft-2/common/sc2_map.cAdd a new static function near
sc2_read_catalog_xml()(~line 350):This mirrors how
sc2_parse_terrain_data()andsc2_parse_light_data()already read from the map source — try forward-slash, then backslash.Step 3: Refactor catalog parsers into
_doc/_file/_sourcevariantsFile:
games/starcraft-2/common/sc2_map.cFor each of the 5 catalog types, extract the XML walking logic into a
_docfunction that takes a pre-parsedxmlDocPtr:sc2_parse_model_catalog_filesc2_parse_model_catalog_doc(catalog, doc)sc2_parse_model_catalog_source(catalog, source)sc2_parse_actor_catalog_filesc2_parse_actor_catalog_doc(catalog, doc)sc2_parse_actor_catalog_source(catalog, source)sc2_parse_unit_catalog_filesc2_parse_unit_catalog_doc(catalog, doc)sc2_parse_unit_catalog_source(catalog, source)sc2_parse_terrain_tex_catalog_filesc2_parse_terrain_tex_catalog_doc(catalog, doc)sc2_parse_terrain_tex_catalog_source(catalog, source)sc2_parse_cliff_catalog_filesc2_parse_cliff_catalog_doc(catalog, doc)sc2_parse_cliff_catalog_source(catalog, source)The
_filevariant keeps the existingsc2_read_catalog_xml(root, filename)call and delegates to_doc.The
_sourcevariant usessc2_read_map_catalog_xml(source, filename)and delegates to_doc.Step 4: Fix
sc2_parse_catalogs()load order and add source parameterFile:
games/starcraft-2/common/sc2_map.cChange signature:
sc2_parse_catalogs(sc2Catalog_t *catalog)→sc2_parse_catalogs(sc2Catalog_t *catalog, sc2MapSource_t *source)New load order (last write wins):
Step 5: Fix terrain/light data catalog load order
File:
games/starcraft-2/common/sc2_map.cBoth
sc2_parse_terrain_data_catalogs()andsc2_parse_light_data_catalogs()should already parseroots[]before""(verify — this was part of PR #100). If not, reverse the order.Step 6: Move
sc2_resolve_catalogs()beforesc2_source_close()File:
games/starcraft-2/common/sc2_map.cChange:
sc2_resolve_catalogs(void)→sc2_resolve_catalogs(sc2MapSource_t *source)In
SC2_MapLoad, move the call from aftersc2_source_close(&source)to before it:Step 7: Update test fixtures to differentiate Core vs Tiny
Files:
games/starcraft-2/tests/resources-src/Mods/Core.SC2Mod/Base.SC2Data/GameData/*.xmlThe Core and Tiny fixtures must have different values for overlapping catalog entries so layering bugs are visible:
ActorData.xmlMarineActorModelMarineCoreModelMarineCatalogModelModelData.xmlMarineCoreModelMarineCatalogModel(Race="Terran")UnitData.xmlMarineRadius0.250.75TerrainTexData.xmlFixtureGrassTextureCoreGrass_Diffuse.ddsFixtureGrass_Diffuse.ddsCliffData.xmlFixtureCliff0CliffMeshCoreCliff0CliffNatural0Step 8: Add
assert_tiny_map_catalog_overrides()test helperFile:
games/starcraft-2/tests/test_sc2_map.cAdd a helper that asserts the Tiny map's map-local values win over Core:
Call from both:
test_sc2_map_loads_xml_objects_and_terrain(archive path)test_sc2_map_loads_directory_fixture_without_generated_layers(disk path)Remove the now-duplicated inline assertions for these fields from both tests.
Validation
All of these must pass before submitting the PR:
References
Related