Skip to content

Honor SC2 catalog dependency layering #101

Description

@corepunch

Problem

SC2 catalog loading has three issues preventing correct map-local overrides:

  1. Duplicate root arrayssc2_parse_catalogs(), sc2_parse_terrain_data_catalogs(), and sc2_parse_light_data_catalogs() each define their own identical roots[] array (DRY violation).

  2. Wrong load ordersc2_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.

  3. No map-local catalog datasc2_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

Resource URL
SC2 Standard Dependencies (Blizzard) https://github.com/SC2Mapster/blizzard-tutorials/blob/master/docs/New_Tutorials/01_Introduction/013_Standard_Dependencies.md
SC2 Data Spaces guide https://s2editor-guides.readthedocs.io/New_Tutorials/04_Data_Editor/data-spaces
SC2ModKit (JS catalog loader) https://github.com/star-tools/modkit
SC2 GameData Documentation https://github.com/chansey97/sc2-gamedata-documentation
SC2Mapster community https://sc2mapster.github.io/mkdocs/data/
Talv sc2-data (Core.SC2Mod structure) https://github.com/Talv/sc2-data/tree/master/mods/core.sc2mod/base.sc2data

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or requeststarcraft-2StarCraft II specific features

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions