17 KiB
Level-cap projection: audit and policy (Phase 8)
Scope: CoA only. Written from the code of the core fork (feat/portable-session-bridge, commit 2f6f9f0ca + modules mod-coa-content-scaling,
mod-coa-playerbots, ...) and from experiments on a real worldserver with MaxPlayerLevel = 60 against the disposable realm databases.
Nothing in this document is assumed from stock WotLK: each statement names the code or the observation behind it.
The invariant the whole phase rests on: the Owner's canonical character is never down-levelled. A projection is a view made for one realm; the full
state (C0) stays canonical, and a realm only ever holds a working copy P0 derived from it.
1. Where the effective cap comes from, and whether it can move
| Question | Answer | Evidence |
|---|---|---|
Source of MaxPlayerLevel |
worldserver.conf MaxPlayerLevel, read once into CONFIG_MAX_PLAYER_LEVEL; range 1..MAX_LEVEL |
WorldConfig.cpp:302 |
| Can it change while the worldserver runs? | No. The key is registered ConfigValueCache::Reloadable::No; nothing in the core or any module calls setIntConfig(CONFIG_MAX_PLAYER_LEVEL) (grep over src/ and modules/); mod-coa-content-scaling itself logs "Progression layout, expansion packs and MaxPlayerLevel cannot be changed at runtime! Server restart required." on a config reload |
CoAContentScaling.cpp:948-955 |
| Does anything clamp a real player above the cap at login? | No. Player::LoadFromDB does SetUInt32Value(UNIT_FIELD_LEVEL, fields[6]) with no comparison (PlayerStorage.cpp:5099); HandlePlayerLoginFromDB has no level check; ObjectMgr::GetXPForLevel returns 0 above the table (ObjectMgr.cpp:8126) and GetPlayerLevelInfo silently uses the cap's row for a higher level. The only clamp in the tree is ClampBotLevel of mod-coa-playerbots (BotMgr.cpp:360), which GiveLevel(cap) + XP = 0 + SaveToDB for bots only (observed: "BotMgr: clamped 'Shaniel' to server level cap 60") |
code + experiment E1 |
Consequence: a level-80 canonical character must never be written to a cap-60 realm as level 80 (the core would run it as an over-level player with no XP table and no stat row of its own); and the cap is a per-process constant, so a change of cap is always a restart, which is a session boundary (section 8).
2. How mod-coa-content-scaling derives its ProgressionLayout
CoAContentScaling::InitializeLayout()(CoAContentScaling.cpp:262) takesmaxPlayerLevelfromCONFIG_MAX_PLAYER_LEVEL,tbcActive = CoAContentScaling TBC pack enabled && pack registered,wotlkActivelikewise, andProgression.Mode(Auto|Custom+ClassicEnd,TbcEnd) frommod-coa-content-scaling.conf; thenProgressionLayout::Create(...)(ProgressionLayout.cpp).Create: cap 80 with both packs and no custom bounds is the stock identity (Classic 1-60, TBC 58-70, WotLK 68-80). Otherwise Classic ends at 45 (cap 60) .. 60 (cap 80) by linear interpolation, TBC at 55 .. 70, WotLK ends at the cap. Observed on the cap-60 realm:Classic 1-45, TBC 45-55, WotLK 55-60.Validate()accepts only60 <= MaxPlayerLevel <= 80. A layout that fails disables scaling (_enabled = false,LOG_FATAL).- The layout is frozen at startup (
OnLoadCustomDatabaseTable->FinalizeAndInitialize);OnAfterConfigLoad(reload=true)only reloads the non-structural options (LoadReloadableConfig). - The module is not part of the core repository (it is copied into
modules/, git-ignored there): the Manager cannot rely on its API, and the core cannot link to it. What the module changes in memory is observable throughsObjectMgr->GetItemTemplate(), which is the surface projection must use.
3. Which ItemTemplate fields are changed in memory
ItemBudgetScaler::ScaleAllItems runs once in OnStartup (CoAContentScaling.cpp:966, guarded by _itemsScaled, skipped when CoAContentScaling.ScaleItems = 0
or scaling is disabled). Per template, unless its policy is PRESERVE (ItemBudgetScaler.cpp:306):
RequiredLevelandItemLevelare replaced by the effective values of the layout;- only when
statMultiplier < 1: everyItemStat[i].ItemStatValue,Damage[i].DamageMin/Max, andArmorare multiplied; - separately (same function of
OnStartup)DungeonProgressionRequirements::reqItemLeveland the LFG dungeons' min/max levels.
Raw RequiredLevel in item_template is therefore not the rule. Observed on the cap-60 realm (.coascale item):
| Item | raw RequiredLevel |
runtime RequiredLevel |
policy |
|---|---|---|---|
| 19019 Thunderfury | 60 | 41 | REVIEW_SPECIAL |
| 1063235 custom dagger | 80 | 60 | TIER_ALIGNED |
| 300000 Valiant Holy Champion's Gloves | 80 | 60 | TIER_ALIGNED |
| 490 Frosty Gauntlets | 70 | 55 | TIER_ALIGNED |
| 2061035 Stormwatcher's Turban (custom) | 80 | 80 | PRESERVE |
| 2069385 Legwraps of Eternal Sorrow (custom) | 80 | 80 | PRESERVE |
Of 60 random weapons/armor authored above 60, 57 compress to <= 60 and 3 (CUSTOM_FALLBACK, PRESERVE) keep 70/80; of 250 random cloth/misc armor
authored above 60, 2 keep 80. So on a cap-60 realm almost everything is legal at 60, and the exceptions are exactly the PRESERVE items, which only the running core can
name. A Manager reading the world database would decide wrongly in both directions.
4. How Player::LoadFromDB reacts
Observed on the cap-60 realm with real characters imported through the Manager (E1-E4), and read in PlayerStorage.cpp:
| State at load | Reaction | Evidence |
|---|---|---|
| level > cap | none for a real player (section 1) | code |
equipped item whose runtime RequiredLevel > level |
CanEquipItem returns EQUIP_ERR_CANT_EQUIP_LEVEL_I, the item is removed from the slot, deleted from the inventory tables and sent by mail ("There were problems with equipping item(s)") |
_LoadInventory 6076-6110, CanEquipItem 2427; E2: a level-60 character with the runtime-80 Turban equipped lost it from slot 0 and got a This item(s) have problems with equipping/storing in inventory mail |
| the same item in a bag / backpack | loads normally (CanStoreItem has no level rule) |
E2 (the Legwraps stayed in the backpack, no mail for them) |
| known spell above the working level | _LoadSpells calls addSpell blindly: no level check, no removal |
PlayerStorage.cpp:6643-6658; E3: after a clamp to 60 the character still had all 1512 spells, 14 of them gated above 60 |
| stock talents | character_talent is empty in all real characters (0 rows in the 2.5M-spell realm); CoA talents are spells (section 5) |
DB |
| glyph rows | _LoadGlyphs copies the rows with no level check; 0 rows exist in the real realm |
Player.cpp:15835, DB |
| active / rewarded quests | no level check in _LoadQuestStatus* (effective quest levels are compressed by the module) |
code |
| pet | Pet::LoadPetFromDB -> SynchronizeLevelWithOwner: a hunter pet above its owner is lowered to the owner's level, a summon follows the owner (Pet.cpp:2366); GivePetXP gives nothing at or above the owner's level |
code |
| class abilities (level-driven) | OnPlayerLevelChanged -> AscensionClassService::SynchronizeProgression (see section 5) |
code, E3 |
So the load itself never holds anything back by level except the equip check, and that check destroys the placement and mails the item: holding must happen before load.
5. How CoA abilities, talents and builds are represented
CoA availability is data driven by level, not SpellInfo::SpellLevel:
AscensionProgression::Ranks(2709 rows{class, first spell of chain, spell, RequiredLevel}),AscensionCompatData::ClassSpells(847),LegacyGeneratedClassSpells(270),TaughtAbilities(20),TalentReplacements(15) are compile-time tables;CoATalentEntries(entry id, class, spec, up to 3 rank spells,AECost,TECost,RequiredLevel), the automatic-entry dependencies and the talent budgets by class and level (CoATalentBudgets {class, level, AE, TE}) are read at startup from the client DBCsCharacterAdvancement.dbc,CharacterAdvancementEssence.dbc,CharacterAdvancementClassTypes.dbc, ... (AscensionCoATalentData.cpp:114).SynchronizeProgression(player)(called at level change, spec change, and on several commands) removes aClassSpells/LegacyGeneratedClassSpellsgrant whoseRequiredLevelexceeds the live level (AscensionCompat.cpp:700-745), and learns missing grants, ranks and automatic talent entries whose level is reached. It never removes aRanksrow's spell, a paid talent, or a spell outside the tables. Observed (E3): 14 of the 142 level-table spells of a real level-80 ranger need > 60 and stay after the level drops to 60.- A paid talent (
AECost/TECost> 0) is a spell incharacter_spell(rank n = the entry's n-thSpellIds); a pick requiresplayer->GetLevel() >= entry.RequiredLevelandspent + cost <= budget(class, level)only when it is chosen (SetTalentRank,AscensionCompat.cpp:1552-1590); a spell already in the book is not re-checked against a lower level or budget. AscensionCoATalentState::KnownEntries / KnownRank / Spentare pure functions of aHasSpellpredicate, so the core can evaluate a character's talent state from a spell set, without aPlayer.- Builds are stored in
character_settingsin three shapes (all integers):core.ascension_slot.<n>:[1, class, spec, N, (entry, rank) * N, M, (button, action) * M, 0...](SpecializationSlotRecord,ParseSpecializationSlot);core.ascension_slot.activeis the active slot;core.ascension_build.<spec>andcore.ascension_slot.<n>.build.<spec>:[count, pick * count]withpick = entry * 10 + rank(StoreBuild);core.ascension_bar.<spec>andcore.ascension_slot.<n>.bar.<spec>:[count, (button, spell) * count](StoreBar). The active slot's record is regenerated from the live spellbook at every save (SaveSlot->StoreSlot(LiveSlot)); an inactive slot's record is stored data and is applied bySwitchSlotthroughApplyKnownEntriesUpload, which refuses entries above the level or over the budget. Observed (E2): after one login and save,core.ascension_slot.0had been rewritten from the live state.
6. The P0 hazard, stated against the code
The Phase 4/5 merge replaces a character_settings source as a whole (cx.map("settings", ...)). Under projection:
C0 core.ascension_slot.0 = full level-80 record (83 entries, actions)
B0 core.ascension_slot.0 = what the level-60 realm regenerated at its first save (the projected subset)
B1 the same, after the player moved one talent at level 60
B0 -> B1 differs, so the whole record would replace C0's and erase every suspended 61-80 entry. Even with no edit at all, any save rewrites the record; the
generic map merge is not enough. The same holds for core.ascension_build.* and the bars (a held spell on a bar button), and for character_action (model actions).
All of them are decomposable (section 5): entries by id, picks by id, buttons by index. They are merged semantically under projection (overlay), and a record that does not parse is
blocked (kept canonical, not applied), never replaced.
7. Classification of portable state under projection
| State | Class | Rule (and why) |
|---|---|---|
progression.level, progression.xp |
Projection-owned | working = (cap, 0); any B0 -> B1 change is working-copy state and never reaches the canonical (level, xp) |
| money, honor, arena points, currencies, titles, explored zones | Apply | level independent; merged by delta as before |
| reputation, rewarded quests, client data, macros, wardrobe, collections, extensions | Apply | level independent |
| active quests | Apply | no level check at load; effective quest levels are compressed by the module |
| skills | Apply | rank caps follow the level at level-up; loading a higher rank does not fail |
items: equipped (slots 0-18) or in a bag slot (19-22) with runtime RequiredLevel > projected level |
HoldByProjection | the load would remove and mail them (section 4) |
| items: anywhere else (bags, bank-side containers, backpack) | Apply | loads normally; the realm refuses to equip them by its own rule |
spells that appear in a CoA level table and whose every granting source needs > projected level |
HoldByProjection | CanPortableSpellExistAtProjectedLevel; the core would not remove them by itself (section 5) |
| spells in no level table (racial, proficiencies, professions, mounts, stock, item-learned) | Apply | level independent |
paid talents: entry RequiredLevel > L, then in data order those that exceed the AE (class tree) / TE (spec tree) budget of L |
HoldByProjection | deterministic: keep the cheapest-level-first prefix that fits; the dependency closure of automatic entries is held with them |
core.ascension_slot.*, core.ascension_build.*, core.ascension_bar.* |
Apply (projected) + overlay merge | held entries/picks/actions are removed from P0 and survive in C0 (section 6) |
| a build record that does not parse | Unsupported | blocked: kept canonical, not applied, reported |
character_action buttons whose action is a held spell |
HoldByProjection | removed from P0, kept canonical |
| stock talents | Unsupported if any | none exist; a row would be refused rather than guessed |
| glyph rows | Apply | no level gate at load; none exist |
| pets | Apply | the core syncs the pet level to the owner at load (a normalisation, not progress) |
| item-linked appearance data | n/a | none (Phase 6) |
8. Policy that follows
- Activation.
canonical.level > destination.max_player_level-> projection active atL = max_player_level; otherwise none. Matrix: 80->80 none, 80->70L=70, 80->60L=60, 70->60L=60, 60->60 none. - Who decides. The running core decides items, spells and builds (section 3: only it knows the post-scaling templates; section 5: only it holds the CoA tables) through one pure function, exposed as the console
command
portable project(a read-only query) and inside the online import job. The result is a manifest: held item ids, held spell ids, heldcharacter_actionbuttons and the projected build vectors, stamped with the core'sprogression_signatureand the policy version. The Manager never evaluates level requirements itself. - Stopped realm. A projection needs a manifest. On a stopped realm the Manager cannot obtain one and refuses explicitly (
ProjectionNeedsRunningCore) unless the caller supplies a manifest taken from a core with the realm's current signature. Nothing is guessed. - P0 is an ordinary working copy.
P0 = C0 - held + (level, 0) + projected builds. Import, update,B0,B1and checkpoints work on it with the Phase 3/4/5 machinery; only the projection-owned parts of the merge are special (level/xp frozen, builds merged as an overlay). Held items map asfiltered, held spells are absent fromB0/B1, so the existing three-way merge keeps them without a second engine. - Session pinning. A session is pinned to
(content_profile_hash, progression_signature, projection policy version, projected level)at the baseline. The pin travels in the (version 2) session messages and the Owner refuses a message under another pin. The signature is constant while a worldserver runs; a different one means a restart, which ends the session; the Host then does not re-arm the old working copy: it must be re-projected under the new profile first, and the realm refuses logins in the meantime (the session row staysended). - Profile change (cap 60 -> 70): the next update re-projects with the new manifest; entries newly allowed at 70 are restored, entries still held until 80 stay canonical-only.
- Capabilities.
RealmCapabilitiesprofile version 2 carriesprogression { max_player_level, projection_protocol, progression_signature, scaling_enabled }. The core'sprogression_signatureis a SHA-256 overMaxPlayerLevel, the content-scaling switches that decide the layout, the policy version, a digest of the compile-time CoA progression tables and the talent data/budgets as loaded, and a digest of the effective(entry, RequiredLevel)of every item template (a few MB hashed once at startup, not the item database). It moves whenever anything above moves.
9. Experiments (cap-60 realm, scratch databases, bot logins = real LoadFromDB)
- E1 level-80 ranger (real, 21 items, 1512 spells) logged in on cap 60: level read as 80 by
LoadFromDB; only the bot manager lowered it ("clamped ... to server level cap 60"), after whichSynchronizeProgressionran at level 60. - E2 level-60 necromancer with a runtime-80 turban equipped and a runtime-80 legwrap in the backpack: the turban left slot 0 and arrived by mail; the legwrap stayed;
core.ascension_slot.0was rewritten at the first save. - E3 spell count 1512 before and after the drop to 60: no ability above 60 was removed (14 of them gated above 60 in the level tables, 5 above 70).
- E4
.coascale itemsamples (section 3).
10. What is deliberately not done in v1
- Projection on a stopped realm without a manifest; any guess from the world database.
- A conflict-resolution UI, Registry, Relay, Wildcard, the personal bank, equipment sets.
- Holding items outside the equipment/bag slots, or holding by rules other than the level (skill, spell, proficiency requirements stay the realm's own business, as in Phase 4).