From 3851e78541f63779223ad27737ef07b9a1c0a43a Mon Sep 17 00:00:00 2001 From: corepunch Date: Sat, 3 Oct 2026 17:15:50 +0200 Subject: [PATCH] wc3: Size destructable pool separately, fix null missile model crash Prologue01 (3852 destructables) and many campaign maps exhausted the 2047-slot lifecycle pools. Measured peak usage on all 97 campaign maps: destructable 4917, every other pool <= 300. Give destructables an 8192-slot pool and keep 2048 for the rest instead of sizing all 27 pools to MAX_ENTITIES (52 MB -> 26 MB static). Log per-pool peaks at shutdown. Make G_RegisterModel return 0 for a null or empty name: TFT X-campaign units with a missile weapon but no art crashed in SV_ModelIndex. Raise the save-load pool count bound accordingly. Co-Authored-By: Claude Sonnet 5.5 --- docs/games/warcraft-3/pooled-entity-state.md | 2 +- games/warcraft-3/game/g_local.h | 9 +- games/warcraft-3/game/g_main.c | 1 + games/warcraft-3/game/g_model.c | 2 + games/warcraft-3/game/g_pool.c | 161 ++++++++++++------- games/warcraft-3/game/g_save.c | 2 +- 6 files changed, 115 insertions(+), 62 deletions(-) diff --git a/docs/games/warcraft-3/pooled-entity-state.md b/docs/games/warcraft-3/pooled-entity-state.md index eeb8b9582..aaae01289 100644 --- a/docs/games/warcraft-3/pooled-entity-state.md +++ b/docs/games/warcraft-3/pooled-entity-state.md @@ -18,7 +18,7 @@ The reference is id Software's [Quake II edict definition](https://github.com/id ## Lifetime -Each pool has `MAX_ENTITIES` slots, with slot zero reserved. This lets persistent records such as map-placed destructables cover the server's full entity capacity; sparse ability pools use only the records their active lifecycles need. Allocation zeroes the selected record. Exhaustion logs the pool name and fails through `gi.error`; it never provides a shared writable fallback. `G_FreeAncientRoot(ent)` releases that record and clears its member. `G_PoolsReleaseEdict()` releases every attached record during entity teardown. Game initialization and map replacement reset all pools and detach all edict pool pointers. +Pools have 2,048 slots (2,047 usable, slot zero reserved), except the destructable pool at 8,192: every map-placed destructable owns a persistent record, and retail campaign maps peak near 5,000 (NightElf04, UndeadX01). All other pools peak at 256 or fewer. Each game shutdown logs `WC3 pool peak =N/cap` for pools that were used; the map audit logs retain it, so rerun `make audit-wc3-maps` after adding a pool or raising content density. Allocation zeroes the selected record. Exhaustion logs the pool name and fails through `gi.error`; it never provides a shared writable fallback. `G_FreeAncientRoot(ent)` releases that record and clears its member. `G_PoolsReleaseEdict()` releases every attached record during entity teardown. Game initialization and map replacement reset all pools and detach all edict pool pointers. Construction state exists only while construction is in progress, including paused Human construction. Completion and stop release it after worker cleanup; `ent->construction != NULL` is the active-state check. There is no separate `active` flag. diff --git a/games/warcraft-3/game/g_local.h b/games/warcraft-3/game/g_local.h index 332edd084..c5d4071c2 100644 --- a/games/warcraft-3/game/g_local.h +++ b/games/warcraft-3/game/g_local.h @@ -3526,10 +3526,13 @@ extern struct level_locals level; /* Sparse lifecycles live in pools (Quake's gclient_t pattern). The edict holds * a null pointer until the behavior starts. Movement, attacks, and health stay * inline because almost every unit touches them. */ -/* A pool must be able to represent every entity the server can allocate. In - * particular, map-placed destructables each own a persistent pool record. */ -#define LIFECYCLE_POOL_CAP MAX_ENTITIES +/* Most pools peak in the hundreds across the retail campaigns (measured with + * G_PoolsReportPeaks). Map-placed destructables each own a persistent record + * and peak near 5,000 (NightElf04, UndeadX01), so that pool is sized separately. */ +#define DESTRUCTABLE_POOL_CAP 8192 +#define LIFECYCLE_POOL_CAP 2048 void G_PoolsReset(void); +void G_PoolsReportPeaks(void); void G_PoolsReleaseEdict(edict_t *ent); void G_FreeConstruction(edict_t *ent); construction_t *G_AllocConstruction(void); diff --git a/games/warcraft-3/game/g_main.c b/games/warcraft-3/game/g_main.c index 3efe6af3b..8c99de6b1 100644 --- a/games/warcraft-3/game/g_main.c +++ b/games/warcraft-3/game/g_main.c @@ -532,6 +532,7 @@ static void G_ShutdownGame(void) { if (g_edicts == NULL) { return; } + G_PoolsReportPeaks(); G_ResetSelectionSoundState(); G_CommandErrorReset(); UI_ResetHud(); diff --git a/games/warcraft-3/game/g_model.c b/games/warcraft-3/game/g_model.c index 5d930aa19..ec0a45582 100644 --- a/games/warcraft-3/game/g_model.c +++ b/games/warcraft-3/game/g_model.c @@ -243,6 +243,8 @@ void G_NormalizeModelFilename(cstring_t authored, string_t out, size_t out_size) } int G_RegisterModel(cstring_t filename) { + /* Units with a missile weapon but no authored Missileart have no model. */ + if (!filename || !filename[0]) return 0; int index = gi.ModelIndex(filename); if (index > 0 && index < G_MAX_MODELS && !g_models[index].filename[0]) strncpy(g_models[index].filename, filename, MAX_PATHLEN - 1); diff --git a/games/warcraft-3/game/g_pool.c b/games/warcraft-3/game/g_pool.c index 10520f0e8..4242b0b78 100644 --- a/games/warcraft-3/game/g_pool.c +++ b/games/warcraft-3/game/g_pool.c @@ -2,12 +2,11 @@ #include #include -#define POOL_CAP LIFECYCLE_POOL_CAP - -#define DEFINE_POOL(member, Name, type) \ +#define DEFINE_POOL(member, Name, type, POOL_CAP) \ static struct { type data; uint16_t next; bool live; } member##_slots[POOL_CAP]; \ static uint16_t member##_free; \ static bool member##_ready; \ +static uint16_t member##_live, member##_peak; \ type *G_Alloc##Name(void) { \ if (!member##_ready) { \ member##_free = 1; \ @@ -23,6 +22,7 @@ type *G_Alloc##Name(void) { \ member##_free = member##_slots[i].next; \ memset(&member##_slots[i], 0, sizeof(member##_slots[i])); \ member##_slots[i].live = true; \ + if (++member##_live > member##_peak) member##_peak = member##_live; \ return &member##_slots[i].data; \ } \ void G_Free##Name(edict_t *ent) { \ @@ -31,38 +31,71 @@ void G_Free##Name(edict_t *ent) { \ uint16_t i = (uint16_t)(((unsigned char *)ent->member - (unsigned char *)member##_slots) / sizeof(member##_slots[0])); \ assert(i > 0 && i < POOL_CAP && member##_slots[i].live); \ member##_slots[i].live = false; \ + member##_live--; \ member##_slots[i].next = member##_free; \ member##_free = i; \ ent->member = NULL; \ } -DEFINE_POOL(construction, Construction, construction_t) -DEFINE_POOL(research, Research, research_t) -DEFINE_POOL(rally, Rally, rally_t) -DEFINE_POOL(food, Food, food_t) -DEFINE_POOL(buildwork, Buildwork, buildwork_t) -DEFINE_POOL(revival, Revival, revival_t) -DEFINE_POOL(sacrifice, Sacrifice, sacrifice_t) -DEFINE_POOL(unsummon, Unsummon, unsummon_t) -DEFINE_POOL(shadowmeld, ShadowMeld, shadowMeld_t) -DEFINE_POOL(militia, Militia, militia_t) -DEFINE_POOL(polymorph, Polymorph, polymorph_t) -DEFINE_POOL(raven, Raven, raven_t) -DEFINE_POOL(blight_growth, BlightGrowth, blightGrowth_t) -DEFINE_POOL(ensnare, Ensnare, ensnare_t) -DEFINE_POOL(ancient_root, AncientRoot, ancientRoot_t) -DEFINE_POOL(goldmine, GoldMine, goldMine_t) -DEFINE_POOL(mineoverlay, MineOverlay, mineOverlay_t) -DEFINE_POOL(acolyte_mine, AcolyteMine, acolyteMine_t) -DEFINE_POOL(item, Item, item_t) -DEFINE_POOL(destructable, Destructable, destructable_t) -DEFINE_POOL(cargo, Cargo, cargo_t) -DEFINE_POOL(stock, Stock, stock_t) -DEFINE_POOL(waygate, Waygate, waygate_t) -DEFINE_POOL(artillery, Artillery, artillery_t) -DEFINE_POOL(avatar, Avatar, avatar_t) -DEFINE_POOL(sleep, Sleep, sleep_t) -DEFINE_POOL(channel, Channel, channel_t) +DEFINE_POOL(construction, Construction, construction_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(research, Research, research_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(rally, Rally, rally_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(food, Food, food_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(buildwork, Buildwork, buildwork_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(revival, Revival, revival_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(sacrifice, Sacrifice, sacrifice_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(unsummon, Unsummon, unsummon_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(shadowmeld, ShadowMeld, shadowMeld_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(militia, Militia, militia_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(polymorph, Polymorph, polymorph_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(raven, Raven, raven_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(blight_growth, BlightGrowth, blightGrowth_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(ensnare, Ensnare, ensnare_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(ancient_root, AncientRoot, ancientRoot_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(goldmine, GoldMine, goldMine_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(mineoverlay, MineOverlay, mineOverlay_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(acolyte_mine, AcolyteMine, acolyteMine_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(item, Item, item_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(destructable, Destructable, destructable_t, DESTRUCTABLE_POOL_CAP) +DEFINE_POOL(cargo, Cargo, cargo_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(stock, Stock, stock_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(waygate, Waygate, waygate_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(artillery, Artillery, artillery_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(avatar, Avatar, avatar_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(sleep, Sleep, sleep_t, LIFECYCLE_POOL_CAP) +DEFINE_POOL(channel, Channel, channel_t, LIFECYCLE_POOL_CAP) + +void G_PoolsReportPeaks(void) { +#define REPORT_POOL(member) if (member##_peak) fprintf(stderr, "WC3 pool peak %s=%u/%d\n", #member, member##_peak, (int)(sizeof(member##_slots) / sizeof(member##_slots[0])) - 1) + REPORT_POOL(construction); + REPORT_POOL(research); + REPORT_POOL(rally); + REPORT_POOL(food); + REPORT_POOL(buildwork); + REPORT_POOL(revival); + REPORT_POOL(sacrifice); + REPORT_POOL(unsummon); + REPORT_POOL(shadowmeld); + REPORT_POOL(militia); + REPORT_POOL(polymorph); + REPORT_POOL(raven); + REPORT_POOL(blight_growth); + REPORT_POOL(ensnare); + REPORT_POOL(ancient_root); + REPORT_POOL(goldmine); + REPORT_POOL(mineoverlay); + REPORT_POOL(acolyte_mine); + REPORT_POOL(item); + REPORT_POOL(destructable); + REPORT_POOL(cargo); + REPORT_POOL(stock); + REPORT_POOL(waygate); + REPORT_POOL(artillery); + REPORT_POOL(avatar); + REPORT_POOL(sleep); + REPORT_POOL(channel); +#undef REPORT_POOL +} void G_PoolsReset(void) { if (g_edicts) { @@ -96,33 +129,33 @@ void G_PoolsReset(void) { g_edicts[i].channel = NULL; } } - construction_ready = false; memset(construction_slots, 0, sizeof(construction_slots)); construction_free = 0; - research_ready = false; memset(research_slots, 0, sizeof(research_slots)); research_free = 0; - rally_ready = false; memset(rally_slots, 0, sizeof(rally_slots)); rally_free = 0; - food_ready = false; memset(food_slots, 0, sizeof(food_slots)); food_free = 0; - buildwork_ready = false; memset(buildwork_slots, 0, sizeof(buildwork_slots)); buildwork_free = 0; - revival_ready = false; memset(revival_slots, 0, sizeof(revival_slots)); revival_free = 0; - sacrifice_ready = false; memset(sacrifice_slots, 0, sizeof(sacrifice_slots)); sacrifice_free = 0; - unsummon_ready = false; memset(unsummon_slots, 0, sizeof(unsummon_slots)); unsummon_free = 0; - shadowmeld_ready = false; memset(shadowmeld_slots, 0, sizeof(shadowmeld_slots)); shadowmeld_free = 0; - militia_ready = false; memset(militia_slots, 0, sizeof(militia_slots)); militia_free = 0; - polymorph_ready = false; memset(polymorph_slots, 0, sizeof(polymorph_slots)); polymorph_free = 0; - raven_ready = false; memset(raven_slots, 0, sizeof(raven_slots)); raven_free = 0; - blight_growth_ready = false; memset(blight_growth_slots, 0, sizeof(blight_growth_slots)); blight_growth_free = 0; - ensnare_ready = false; memset(ensnare_slots, 0, sizeof(ensnare_slots)); ensnare_free = 0; - ancient_root_ready = false; memset(ancient_root_slots, 0, sizeof(ancient_root_slots)); ancient_root_free = 0; - goldmine_ready = false; memset(goldmine_slots, 0, sizeof(goldmine_slots)); goldmine_free = 0; - mineoverlay_ready = false; memset(mineoverlay_slots, 0, sizeof(mineoverlay_slots)); mineoverlay_free = 0; - acolyte_mine_ready = false; memset(acolyte_mine_slots, 0, sizeof(acolyte_mine_slots)); acolyte_mine_free = 0; - item_ready = false; memset(item_slots, 0, sizeof(item_slots)); item_free = 0; - destructable_ready = false; memset(destructable_slots, 0, sizeof(destructable_slots)); destructable_free = 0; - cargo_ready = false; memset(cargo_slots, 0, sizeof(cargo_slots)); cargo_free = 0; - stock_ready = false; memset(stock_slots, 0, sizeof(stock_slots)); stock_free = 0; - waygate_ready = false; memset(waygate_slots, 0, sizeof(waygate_slots)); waygate_free = 0; - artillery_ready = false; memset(artillery_slots, 0, sizeof(artillery_slots)); artillery_free = 0; - avatar_ready = false; memset(avatar_slots, 0, sizeof(avatar_slots)); avatar_free = 0; - sleep_ready = false; memset(sleep_slots, 0, sizeof(sleep_slots)); sleep_free = 0; - channel_ready = false; memset(channel_slots, 0, sizeof(channel_slots)); channel_free = 0; + construction_ready = false; construction_live = construction_peak = 0; memset(construction_slots, 0, sizeof(construction_slots)); construction_free = 0; + research_ready = false; research_live = research_peak = 0; memset(research_slots, 0, sizeof(research_slots)); research_free = 0; + rally_ready = false; rally_live = rally_peak = 0; memset(rally_slots, 0, sizeof(rally_slots)); rally_free = 0; + food_ready = false; food_live = food_peak = 0; memset(food_slots, 0, sizeof(food_slots)); food_free = 0; + buildwork_ready = false; buildwork_live = buildwork_peak = 0; memset(buildwork_slots, 0, sizeof(buildwork_slots)); buildwork_free = 0; + revival_ready = false; revival_live = revival_peak = 0; memset(revival_slots, 0, sizeof(revival_slots)); revival_free = 0; + sacrifice_ready = false; sacrifice_live = sacrifice_peak = 0; memset(sacrifice_slots, 0, sizeof(sacrifice_slots)); sacrifice_free = 0; + unsummon_ready = false; unsummon_live = unsummon_peak = 0; memset(unsummon_slots, 0, sizeof(unsummon_slots)); unsummon_free = 0; + shadowmeld_ready = false; shadowmeld_live = shadowmeld_peak = 0; memset(shadowmeld_slots, 0, sizeof(shadowmeld_slots)); shadowmeld_free = 0; + militia_ready = false; militia_live = militia_peak = 0; memset(militia_slots, 0, sizeof(militia_slots)); militia_free = 0; + polymorph_ready = false; polymorph_live = polymorph_peak = 0; memset(polymorph_slots, 0, sizeof(polymorph_slots)); polymorph_free = 0; + raven_ready = false; raven_live = raven_peak = 0; memset(raven_slots, 0, sizeof(raven_slots)); raven_free = 0; + blight_growth_ready = false; blight_growth_live = blight_growth_peak = 0; memset(blight_growth_slots, 0, sizeof(blight_growth_slots)); blight_growth_free = 0; + ensnare_ready = false; ensnare_live = ensnare_peak = 0; memset(ensnare_slots, 0, sizeof(ensnare_slots)); ensnare_free = 0; + ancient_root_ready = false; ancient_root_live = ancient_root_peak = 0; memset(ancient_root_slots, 0, sizeof(ancient_root_slots)); ancient_root_free = 0; + goldmine_ready = false; goldmine_live = goldmine_peak = 0; memset(goldmine_slots, 0, sizeof(goldmine_slots)); goldmine_free = 0; + mineoverlay_ready = false; mineoverlay_live = mineoverlay_peak = 0; memset(mineoverlay_slots, 0, sizeof(mineoverlay_slots)); mineoverlay_free = 0; + acolyte_mine_ready = false; acolyte_mine_live = acolyte_mine_peak = 0; memset(acolyte_mine_slots, 0, sizeof(acolyte_mine_slots)); acolyte_mine_free = 0; + item_ready = false; item_live = item_peak = 0; memset(item_slots, 0, sizeof(item_slots)); item_free = 0; + destructable_ready = false; destructable_live = destructable_peak = 0; memset(destructable_slots, 0, sizeof(destructable_slots)); destructable_free = 0; + cargo_ready = false; cargo_live = cargo_peak = 0; memset(cargo_slots, 0, sizeof(cargo_slots)); cargo_free = 0; + stock_ready = false; stock_live = stock_peak = 0; memset(stock_slots, 0, sizeof(stock_slots)); stock_free = 0; + waygate_ready = false; waygate_live = waygate_peak = 0; memset(waygate_slots, 0, sizeof(waygate_slots)); waygate_free = 0; + artillery_ready = false; artillery_live = artillery_peak = 0; memset(artillery_slots, 0, sizeof(artillery_slots)); artillery_free = 0; + avatar_ready = false; avatar_live = avatar_peak = 0; memset(avatar_slots, 0, sizeof(avatar_slots)); avatar_free = 0; + sleep_ready = false; sleep_live = sleep_peak = 0; memset(sleep_slots, 0, sizeof(sleep_slots)); sleep_free = 0; + channel_ready = false; channel_live = channel_peak = 0; memset(channel_slots, 0, sizeof(channel_slots)); channel_free = 0; } void G_PoolsReleaseEdict(edict_t *ent) { @@ -236,4 +269,18 @@ TEST(wc3_pools, release_reuses_zeroed_owned_state) { G_PoolsReset(); T_NULL(unit->ancient_root); } + +TEST(wc3_pools, destructable_pool_holds_more_than_the_default_cap) { + reset_entities(); + edict_t *first = G_Spawn(); + first->destructable = G_AllocDestructable(); + FOR_LOOP(i, DESTRUCTABLE_POOL_CAP - 2) T_ASSERT(G_AllocDestructable() != NULL); + G_PoolsReset(); + T_NULL(first->destructable); +} + +TEST(wc3_pools, register_model_ignores_missing_names) { + T_EQ(G_RegisterModel(NULL), 0); + T_EQ(G_RegisterModel(""), 0); +} #endif diff --git a/games/warcraft-3/game/g_save.c b/games/warcraft-3/game/g_save.c index c55b13300..4ae5fdb49 100644 --- a/games/warcraft-3/game/g_save.c +++ b/games/warcraft-3/game/g_save.c @@ -1977,7 +1977,7 @@ static bool WritePool(FILE *f, savePool_t const *pool) { static bool ReadPool(FILE *f, savePool_t const *pool) { uint32_t count; - if (!LoadBytes(f, &count, sizeof(count)) || count >= LIFECYCLE_POOL_CAP || count > globals.num_edicts) return false; + if (!LoadBytes(f, &count, sizeof(count)) || count >= DESTRUCTABLE_POOL_CAP || count > globals.num_edicts) return false; FOR_LOOP(n, count) { uint32_t index; if (!LoadBytes(f, &index, sizeof(index)) || index >= globals.num_edicts ||