From 8ad06fd78a737add44c7fc090221b2b6e774d60b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=94=D0=BC=D0=B8=D1=82=D1=80=D0=B8=D0=B9?= Date: Tue, 27 Oct 2026 17:24:50 +0500 Subject: [PATCH] feat(manager): configure per-race character creation --- PER_RACE_TOGGLES_AUDIT.md | 228 ++++++++ .../20261027-per-race-server-settings.md | 7 + crates/coa-core/src/custom_races.rs | 33 +- crates/coa-core/src/lib.rs | 1 + crates/coa-core/src/modules.rs | 14 + crates/coa-core/src/race_availability.rs | 515 ++++++++++++++++++ docs/PER_RACE_EXISTING_FILTER_RESEARCH.md | 165 ++++++ docs/PER_RACE_TOGGLES.md | 131 +++++ docs/PER_RACE_TOGGLES_TESTS.md | 92 ++++ src-tauri/src/lib.rs | 28 + src/i18n/locales/de.ts | 13 + src/i18n/locales/en.ts | 13 + src/i18n/locales/es.ts | 13 + src/i18n/locales/fr.ts | 13 + src/i18n/locales/ru.ts | 13 + src/i18n/locales/zh.ts | 13 + src/lib/api.ts | 13 + src/screens/ModulesPage.tsx | 16 +- src/screens/RaceAvailabilitySettings.tsx | 120 ++++ tools/race_availability_poc.py | 184 +++++++ tools/race_visibility_lua_poc.py | 59 ++ tools/test_race_availability_mpq.py | 108 ++++ tools/test_race_availability_poc.py | 113 ++++ tools/test_race_visibility_lua_poc.py | 157 ++++++ 24 files changed, 2058 insertions(+), 4 deletions(-) create mode 100644 PER_RACE_TOGGLES_AUDIT.md create mode 100644 changelog.d/20261027-per-race-server-settings.md create mode 100644 crates/coa-core/src/race_availability.rs create mode 100644 docs/PER_RACE_EXISTING_FILTER_RESEARCH.md create mode 100644 docs/PER_RACE_TOGGLES.md create mode 100644 docs/PER_RACE_TOGGLES_TESTS.md create mode 100644 src/screens/RaceAvailabilitySettings.tsx create mode 100644 tools/race_availability_poc.py create mode 100644 tools/race_visibility_lua_poc.py create mode 100644 tools/test_race_availability_mpq.py create mode 100644 tools/test_race_availability_poc.py create mode 100644 tools/test_race_visibility_lua_poc.py diff --git a/PER_RACE_TOGGLES_AUDIT.md b/PER_RACE_TOGGLES_AUDIT.md new file mode 100644 index 0000000..f3ff88c --- /dev/null +++ b/PER_RACE_TOGGLES_AUDIT.md @@ -0,0 +1,228 @@ +# Per-race availability audit + +Date: 2026-10-27. This is an internal implementation audit, not a supported-feature guide. + +## Frozen sources + +Separate `feat/per-race-toggles` branches and checkouts: + +| Component | Checkout | Fetched base | +| --- | --- | --- | +| Manager | `C:/games/source/per-race-manager` | `origin/master`, `925a81f` | +| Core | `C:/games/source/per-race-core` | `origin/coa-bots`, `1c658cab46d5057a8972cebd84c840aa6fbc4a91` | +| CoA Companions | `C:/games/source/per-race-companions` | `origin/master`, `6a289fc2230ce9425924ca86dee02d0bb8d3d344` | +| Build | `C:/games/source/per-race-build` | `origin/main`, `89795b6` | + +The build runbook explicitly selects `Corfirean/azerothcore-wotlk-coa:coa-bots`, not its `main`. +The old `C:/games/source/server/azerothcore-wotlk-coa` checkout instead tracks jealous-sound upstream +and has local modifications. It must not be used as the feature's release base. +The primary Manager and Companions checkouts also contain unrelated work and remain untouched. + +SQUID is a separate repository, `Zyth45/mod-playerbots`, not `Corfirean/mod-coa-playerbots`. +Existing research checkout: `C:/games/source/squid-playerbots`. Its current local branch is +`fix/custom-races-existing-bots`, one commit ahead of `origin/coa`. Build policy selects the highest +stable numeric tag, so that local branch must not silently become the shipped dependency. + +## Manager integration points + +- `crates/coa-core/src/custom_races.rs`: package version `1.9.2`, release URLs, MPQ/root/addon lists, + hash manifests, client status, install, enable/disable and uninstall. +- `PatchTransaction`, `SavedFile`, `recover_patch_transaction`: shared client-relative journal in + `.coa-races-transaction/journal.json`, saved files and `committed` marker. Uncommitted changes roll back. + Paths use `fsx::safe_join`; file moves handle cross-volume addon locations. +- `patch_lock`: exclusive OS file lock on `.coa-races.lock`. The empty file's existence is normal and + does not prove another operation is active. Do not delete it as a stale-lock workaround. +- Client mutations reject `client::is_running`; reuse this gate and lock for availability changes. +- `patch_files`, `matches_patch`, `preserve_original`: ownership and preservation depend on manifests + and hashes, not just a filename. Availability overlay ownership needs its own verified record. +- `modules/catalog.json`: `custom-races` uses `coa_custom_races.conf` and `CoACustomRaces.Enable`. + Companions use `mod_coa_playerbots.conf` / `CoaBots.Enable`; SQUID uses + `playerbots.conf` / `AiPlayerbot.Enabled`. +- `crates/coa-core/src/modules.rs`: `toggle`, `settings_in`, `save_settings_in` write current module + configuration. Bot switches enforce stopped servers and the existing control lock. +- `src/screens/ModulesPage.tsx`: Custom Races card, master toggle, install/progress/status UI. +- `src/lib/api.ts` and `src-tauri/src/lib.rs`: existing `custom_races_status`, + `custom_races_install`, `custom_races_uninstall`, `custom_races_toggle_client` commands. + There is no per-race command or verified availability status yet. +- `src-tauri/src/lib.rs::play`: local launch reads `realms::state`, calls `client::sync_realm`, then + launches. Remote launch has a separate realmlist override path; it cannot infer a remote race policy. +- `crates/coa-core/src/realms.rs`: modes CoA, Wildcard and Warcraft Reborn; current configs are captured + under `Settings/realm-profiles/.json`. Use this mechanism rather than a parallel realm store. + Existing bot restrictions for non-CoA modes must remain effective. +- `crates/coa-core/src/clientdl.rs::apply`: manifest-based client updates, `keep_modified`, staging, + preservation of replaced files. No availability-overlay invalidation or rebuild exists today. + +### Confirmed reconciliation risk + +`custom_races.rs::sync_bot_races_config` writes a replacement `AiPlayerbot.ExcludedBotRaces` value: +recommended full exclusions or experimental `65,72,77,80,81`. It does not preserve arbitrary user +exclusions. `modules.rs` calls it from the master switch and BotsEnable saving; some callers discard +errors. Per-race integration cannot rely on this overwrite behavior. + +`playerbots_conf_paths` can also copy a `.dist` file when no active config exists; it is not a purely +read-only discovery API. Status inspection should avoid adding more implicit writes. + +## Core gates and bypasses + +- `modules/mod-coa-custom-races/src/mod_coa_custom_races.cpp`: `WorldScript` loads the master switch; + `AccountScript::CanAccountCreateCharacter` accepts stock IDs 1–8, 10, 11 or an enabled master switch. + No disabled-ID list is implemented. Stock ID 9 is deliberately not included. +- `modules/mod-coa-custom-races/conf/coa_custom_races.conf.dist`: Enable and BotsEnable exist; + DisabledRaceIds does not. +- `src/server/game/Handlers/CharacterHandler.cpp`: normal character creation invokes + `sScriptMgr->CanAccountCreateCharacter` inside its finalization callback and rejects with + `CHAR_CREATE_DISABLED`. A packet from an unpatched client reaches that gate too. +- `src/server/game/Scripting/ScriptDefines/AccountScript.cpp`: hook aggregation must be preserved. +- `src/server/game/DataStores/DBCStores.cpp::LoadDBCStores`: chooses `DataDir/dbc_races/` if + the master switch is enabled and that file exists, otherwise `DataDir/dbc/`. + Per-race creation policy must not remove DBC rows or change login data. +- `.agents/docs/systems/ascension-character-creation.md`: native client compatibility depends on + `CharBaseInfo.dbc`; race button indices are not RaceIDs. Existing race/class checks must stay intact. + +## Both bot systems + +### CoA Companions + +`module/src/BotSpawnRandom.cpp::GetValidBotRaces` reads the global and bot custom-race switches, +`CoaBots.CustomRaces.Enable`, and both AiPlayerbot/CoaBots exclusion lists. It checks DBC presence. +Its experimental defaults apply only when the combined exclusion set is empty: adding an owner list +to that set would silently remove those defaults. Keep those policies independent. + +`CreateBotClone`, `CreateOneRandomBot`, and leveled spawning clone SQL character data directly; +they bypass the account creation hook. Enforce new-character policy at the common clone creation +entry as well as random selection. Preserve existing bots and faction selection via +`Player::TeamIdForRace`. + +Additional confirmed callers: `module/src/BotBattlegroundFill.cpp::QueueBotForSide` and +`module/src/BotLfgFill.cpp` call `CreateOneRandomBot` after trying to reuse an existing bot. +The existing-bot reuse path must remain separate from creation restrictions. + +`tools/offline_bot_factory.py` is another direct-SQL creation path. Its main loop chooses a +template by class and faction, then copies `template.race` into the new character overrides. +It currently does not read the custom-race creation policy. Reject or filter prohibited +templates before generating new accounts, GUIDs or SQL; do not rewrite their races. + +### SQUID Playerbots + +`src/PlayerbotAIConfig.cpp` loads `AiPlayerbot.ExcludedBotRaces` and adds +`AiPlayerbot.CrashingBotRaces`; it reads BotsEnable. `src/CoaBotRacePolicy.h` implements +`IsCoaBotRaceAllowed`. `src/Bot/Factory/RandomPlayerbotFactory.cpp::CreateRandomBot` applies +that policy before faction and race/class selection. + +The same predicate also appears in `src/Bot/RandomPlayerbotMgr.cpp` and +`src/Bot/PlayerbotMgr.cpp`, including existing-character admission. Do not append owner creation +restrictions globally to this shared exclusion vector: that would also affect existing bots. +The predicate currently does not itself require `CoACustomRaces.Enable`. +`RandomPlayerbotFactory.cpp` calls `Player::Create` directly without the account hook. +Use a creation-only check in this factory, leaving existing-character admission unchanged. + +## Portable Characters + +Manager import code is under `crates/coa-core/src/portable/realm/`. +`import.rs` validates identity namespaces and race/class start data (`UnknownRaceClass`). +Availability policy must not become an import rejection or an identity rewrite. The inspected +creation hook is in the normal packet handler, not this importer. In the core, +`src/server/coa/CoAPortableImport.cpp` checks `GetPlayerInfo(model.Race, model.Class)` and writes +`model.Race` to the imported character and character cache. It does not invoke the account +creation hook. Preserve this separation. Live compatibility is still untested. + +## Client experiment, actual observations + +The owner updated `C:/games/Ascension` from 1.9.1 to 1.9.2 during this audit; its manifest now says +`# CoA Custom 1.9.2`. A separate physical client copy is being prepared under +`C:/games/source/per-race-evidence/client`; game assets are outside Git. The copy completed: +25646 files copied, zero failures. The separate server file copy also completed, but has not +been configured or started. No database service has been started for this task. + +Read-only MPQ membership probing found ChrRaces in patch-ZZZF, ZZZE, ZZZC, ZZZ, ZZW, ZZV, ZZU, +ZZS, T, M and several locale archives. CharacterCreate.lua has multiple owners too. +The old `mpqprobe.py` precedence model explicitly admits an inference for Ascension multi-letter +archives. Therefore `patch-ZZZF.MPQ` is a candidate source, not runtime-confirmed effective data. +`patch-ZZZZ.MPQ` exists already; no overlay filename has been qualified. + +Extracted from `patch-ZZZF.MPQ`: + +- WDBC, 95 records, 69 fields, record size 276, string block 2428, total bytes 28668. +- SHA-256 `b78635b08bf0bd63d2898fce97c1fb162c8a9c41632703172eefa88bae79061f`. +- Actual rows: Sethrak 82, Gnoll 83, Saberon 97. Their Flags are 12. +- String block starts with `Hu`, not NUL; imposing an initial empty-string invariant incorrectly + rejects this real table. String boundaries and referenced offsets still need validation. +- Sethrak experiment changes only Flags 12 → 13. All other bytes compare equal. +- One generated MPQ was read back through StormLib; its DBC equals the expected patched bytes. +- The installed `pympq` Python extension crashed during interpreter finalization. The experimental + tool instead uses the installed Unicode StormLib DLL directly. It is not a runtime dependency + proposal for Manager or its users. + +`tools/race_availability_poc.py` requires an explicit source archive and creates output only in a new +directory. It does not select an archive order, install an overlay or assert in-game success. +`tools/test_race_availability_poc.py` uses synthetic bytes only. + +### Background verification after interactive testing was deferred + +The owner requested no screen capture/input and no client/server/MySQL launches while other bot +tests run. These restrictions remain active. The owner reported that the copied client launches; +this does not confirm creation behavior or effective DBC loading. + +- 17 DBC/fingerprint tests and 8 synthetic MPQ integration tests pass (25 total, none skipped). +- Native tests use the explicitly selected existing Unicode StormLib DLL, with no new install. +- Output is built and verified in a temporary directory before publishing the offline result folder. + Failures leave no published result; this is not the future client transaction implementation. +- One and fifty disabled races both produce one MPQ. StormLib adds an internal `(listfile)`; + the sole game payload is `DBFilesClient/ChrRaces.dbc`. +- Every referenced localized race-name offset is bounds-checked; invalid UTF-8 is rejected. +- The experimental fingerprint includes original DBC SHA-256, normalized IDs, algorithm version + and explicitly recorded profile. A profile label does not prove client compatibility. +- The real three-race offline experiment is in + `C:/games/source/per-race-evidence/multiple-races-offline/report.json`. + Source MPQ SHA-256: `40eac695cfcafb598b8376f3b9a61ecc037e4d445b87279cfe387a91edad32c3`. + Generated DBC SHA-256: `243fb36837b76ebd6943da574cc095be548b4530be7f06487debea6703db10d3`. + +The first copy launch showed a missing-critical-files message and subsequently crashed on exit. +The crash report identifies `AMDXN32.DLL` in the AMD graphics driver at the fault address; this +does not establish the root cause. EXE, Extensions, dinput8, d3d9, dxgi, EsteriaAppearance and +patch-ZZZF hashes matched the primary client during comparison. The experimental overlay was +not installed. No graphics-driver or client-runtime changes were made to work around the crash. + +## Reference implementations and update preservation + +Inspected read-only reference clones: + +- `aspollon/AFK-Realm` at `ea9c5bf691c98cace8cc03c06183c161022946de`. +- `aspollon/mod-custom-races` at `5a8f2ff8a0d494ffadf64e677a438aa7f5a6671b`. + +`mod-custom-races/afk-realm.json` declares client patch generation and GlueXML Lua insertion. +`client/CustomRaces.lua` extends and lays out buttons, using race names/files and configured IDs. +This is a reference, not evidence of compatibility with the current CoA/Esteria UI or a reason +to replace CSM's prebuilt-server distribution. + +`crates/coa-core/src/config/merge.rs` performs additive `.dist` merging: missing keys are added, +existing user values and unknown keys are retained. A new DisabledRaceIds key should use this +path. Existing realm snapshots already capture module `.conf` files. Neither mechanism currently +coordinates an availability overlay with client update/repair; that remains implementation work. + +## Open gates + +Update after the owner found the existing Hide Races panel: see +[existing-filter research](docs/PER_RACE_EXISTING_FILTER_RESEARCH.md). The inspected Lua already +supports personal name/index-based hiding, but it is not a realm-scoped RaceID policy. Nine +offline Lua 5.1 probes confirmed key behavior and limitations. Reusing its layout/UI is now a +candidate to investigate before choosing a production DBC implementation. This is not evidence +that the DBC approach failed, and it does not qualify client support. + +The owner subsequently confirmed the GlueXML probe: restricted races disappeared from selection +and were grey, unchecked and disabled in Hide Races. The production client integration should +reuse this filter; changing DBC flags is no longer the selected implementation path. + +Server controls, strict catalog-bound configuration, the core creation hook and both bot factory +guards are implemented and have passed offline checks and native builds. Existing-bot admission +and user/crashing exclusions remain separate from the new creation policy. + +Remaining gates: +1. Integrate automatic client reconciliation, ownership and recovery with realm settings. +2. Qualify client update/repair and shared-client PLAY behavior. +3. Verify live creation rejection/re-enable, existing-character login, imports and both bot systems + when the owner's database test environment becomes available. + +Status: **PARTIAL** — server implementation and builds pass; live server acceptance and production +client synchronization remain pending. See docs/PER_RACE_TOGGLES_TESTS.md for exact scope. +No existing character data, production service, release channel, or primary client archive was changed. diff --git a/changelog.d/20261027-per-race-server-settings.md b/changelog.d/20261027-per-race-server-settings.md new file mode 100644 index 0000000..68ffe45 --- /dev/null +++ b/changelog.d/20261027-per-race-server-settings.md @@ -0,0 +1,7 @@ +--- +area: manager +type: added +audience: admins +title: Configure custom race availability for new characters +--- +Choose individual races in a compact table inside module settings using the installed server catalog. Saving requires a compatible server build and a stopped server. Client synchronization is not yet automatic. diff --git a/crates/coa-core/src/custom_races.rs b/crates/coa-core/src/custom_races.rs index 5a2cdff..6b4cb6b 100644 --- a/crates/coa-core/src/custom_races.rs +++ b/crates/coa-core/src/custom_races.rs @@ -111,6 +111,13 @@ pub fn playerbots_conf_paths(root: &Path) -> Vec { /// Checks whether playerbots are configured to allow custom races. /// Returns true if AiPlayerbot.ExcludedBotRaces exists and does not exclude general custom races. pub fn is_bot_custom_races_allowed(root: &Path) -> Option { + if let Ok(bytes) = fs::read(root.join("Core/configs/modules/coa_custom_races.conf")) { + if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + if let Some(value) = conf.get("CoACustomRaces.BotsEnable") { + return Some(matches!(value.trim().trim_matches('"'), "1" | "true")); + } + } + } for path in playerbots_conf_paths(root) { if let Ok(bytes) = fs::read(&path) { if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { @@ -142,6 +149,15 @@ pub fn sync_bot_races_config(root: &Path, allow_custom_races: bool) -> Result<() for path in playerbots_conf_paths(root) { if let Ok(bytes) = fs::read(&path) { if let Ok(mut conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) { + if let Some(value) = conf.get("AiPlayerbot.ExcludedBotRaces") { + let clean = value.trim().trim_matches(['"', '\'']); + let owned = String::from_utf8_lossy(&bytes) + .contains("CoA Custom: races new random bots never get") + && matches!(clean, EXCLUDED_BOT_RACES_RECOMMENDED | EXCLUDED_BOT_RACES_EXPERIMENTAL); + if !owned { + continue; + } + } conf.set( "AiPlayerbot.ExcludedBotRaces", &format!("\"{excluded}\""), @@ -1621,7 +1637,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let conf = temp.path().join("Core/configs/modules/playerbots.conf"); fs::create_dir_all(conf.parent().unwrap()).unwrap(); - fs::write(&conf, "AiPlayerbot.ExcludedBotRaces = \"\"\n").unwrap(); + fs::write(&conf, "AiPlayerbot.Enabled = 1\n").unwrap(); sync_bot_races_config(temp.path(), false).unwrap(); assert_eq!(is_bot_custom_races_allowed(temp.path()), Some(false)); let parsed = @@ -1639,4 +1655,19 @@ mod tests { ); } } + + #[test] + fn bot_race_sync_preserves_user_and_crashing_exclusions() { + let temp = tempfile::tempdir().unwrap(); + let conf = temp.path().join("Core/configs/modules/playerbots.conf"); + fs::create_dir_all(conf.parent().unwrap()).unwrap(); + for value in ["", "1,82,97", EXCLUDED_BOT_RACES_RECOMMENDED] { + let original = format!("AiPlayerbot.ExcludedBotRaces = \"{value}\"\nAiPlayerbot.CrashingBotRaces = \"72,77,97\"\n"); + fs::write(&conf, &original).unwrap(); + sync_bot_races_config(temp.path(), true).unwrap(); + assert_eq!(fs::read_to_string(&conf).unwrap(), original); + sync_bot_races_config(temp.path(), false).unwrap(); + assert_eq!(fs::read_to_string(&conf).unwrap(), original); + } + } } diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index e6db8d7..efb7827 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -48,6 +48,7 @@ pub mod population; pub mod portable; pub mod process; pub mod ra; +pub mod race_availability; pub mod realm_registry; pub mod realmlist; pub mod realms; diff --git a/crates/coa-core/src/modules.rs b/crates/coa-core/src/modules.rs index 75845fc..727b44c 100644 --- a/crates/coa-core/src/modules.rs +++ b/crates/coa-core/src/modules.rs @@ -516,6 +516,20 @@ fn save_settings_in( let mut changed = Vec::new(); for (key, value) in changes { let value = value.trim(); + if id == "custom-races" && key == crate::race_availability::KEY { + require_bot_server_stopped(root)?; + let catalog = crate::race_availability::status(root)?; + if !catalog.server_config_supported { + return Err(Error::Invalid("Update the server before configuring individual race restrictions.".into())); + } + crate::race_availability::parse_disabled(value, &catalog.races)?; + } + if id == "custom-races" && key == crate::race_availability::CATALOG_KEY { + let hash = value.trim_matches('"'); + if !hash.is_empty() && (hash.len() != 64 || !hash.bytes().all(|byte| byte.is_ascii_hexdigit())) { + return Err(Error::Invalid("Invalid race catalog fingerprint.".into())); + } + } if let Some(field) = fields.get(key) { crate::squid::validate(field, value)?; } diff --git a/crates/coa-core/src/race_availability.rs b/crates/coa-core/src/race_availability.rs new file mode 100644 index 0000000..0080f71 --- /dev/null +++ b/crates/coa-core/src/race_availability.rs @@ -0,0 +1,515 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use serde::Serialize; +use sha2::{Digest, Sha256}; + +use crate::config::parser::ConfFile; +use crate::error::{Error, Result}; + +pub const KEY: &str = "CoACustomRaces.DisabledRaceIds"; +pub const CATALOG_KEY: &str = "CoACustomRaces.DisabledRaceCatalogSha256"; +const CONF: &str = "Core/configs/modules/coa_custom_races.conf"; + +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Race { + pub id: u32, + pub name: String, + pub faction: Option, +} + +#[derive(Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct Availability { + pub realm: crate::realms::Mode, + pub races: Vec, + pub disabled_race_ids: BTreeSet, + pub catalog_sha256: String, + pub server_config_supported: bool, + pub catalog_needs_review: bool, + pub unrecognized_race_ids: BTreeSet, +} + +fn invalid(message: &str) -> Error { + Error::Invalid(message.into()) +} + +fn stock(id: u32) -> bool { + (1..=11).contains(&id) && id != 9 +} + +pub fn catalog(bytes: &[u8]) -> Result> { + let word = |offset: usize| -> Result { + let data = bytes + .get(offset..offset + 4) + .ok_or_else(|| invalid("Truncated race catalog."))?; + Ok(u32::from_le_bytes(data.try_into().unwrap())) + }; + if bytes.get(..4) != Some(b"WDBC") || word(8)? != 69 || word(12)? != 276 { + return Err(invalid("Unsupported race catalog format.")); + } + let count = word(4)? as usize; + let string_size = word(16)? as usize; + if count == 0 || count > 255 { + return Err(invalid("Invalid race catalog record count.")); + } + let strings_start = 20 + count * 276; + if strings_start.checked_add(string_size) != Some(bytes.len()) || string_size == 0 { + return Err(invalid("Invalid race catalog bounds.")); + } + let strings = &bytes[strings_start..]; + if strings.last() != Some(&0) { + return Err(invalid("Invalid race catalog strings.")); + } + let mut ids = BTreeSet::new(); + let mut races = Vec::new(); + for row in 0..count { + let offset = 20 + row * 276; + let id = word(offset)?; + if id == 0 || id > 255 || !ids.insert(id) { + return Err(invalid("Invalid or duplicate RaceID in the catalog.")); + } + let mut name = String::new(); + for field in 14..30 { + let index = word(offset + field * 4)? as usize; + let remaining = strings + .get(index..) + .ok_or_else(|| invalid("Invalid race name offset."))?; + let end = remaining + .iter() + .position(|byte| *byte == 0) + .ok_or_else(|| invalid("Unterminated race name."))?; + let value = std::str::from_utf8(&remaining[..end]) + .map_err(|_| invalid("Invalid race name encoding."))?; + if name.is_empty() && !value.is_empty() { + name = value.into(); + } + } + if stock(id) || word(offset + 4)? & 1 != 0 { + continue; + } + if name.trim().is_empty() { + return Err(invalid("A playable custom race has no name.")); + } + let faction = match word(offset + 13 * 4)? { + 0 => Some("alliance".into()), + 1 => Some("horde".into()), + _ => None, + }; + races.push(Race { id, name, faction }); + } + races.sort_by_key(|race| race.id); + Ok(races) +} + +fn parse_ids(value: &str) -> Result> { + let value = value.trim(); + let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 { + &value[1..value.len() - 1] + } else { + value + }; + if value.trim().is_empty() { + return Ok(BTreeSet::new()); + } + let mut disabled = BTreeSet::new(); + for token in value.split(',') { + let token = token.trim(); + if token.is_empty() || !token.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(invalid( + "Disabled RaceIDs must be a comma-separated list of whole numbers.", + )); + } + let id = token + .parse::() + .map_err(|_| invalid("Invalid disabled RaceID."))?; + if id == 0 || id > 255 || stock(id) || !disabled.insert(id) { + return Err(invalid( + "Disabled RaceIDs contain a stock, unknown or duplicate race.", + )); + } + } + Ok(disabled) +} + +pub fn parse_disabled(value: &str, races: &[Race]) -> Result> { + let disabled = parse_ids(value)?; + let known: BTreeSet = races.iter().map(|race| race.id).collect(); + if !disabled.is_subset(&known) { + return Err(invalid("Disabled RaceIDs contain an unknown race.")); + } + Ok(disabled) +} + +fn dbc_path(root: &Path) -> Result { + let config_path = root.join("Core/configs/worldserver.conf"); + if config_path.is_file() { + let conf = ConfFile::parse_bytes(&fs::read(config_path)?)?; + let value = conf.get("DataDir").unwrap_or("."); + let data = PathBuf::from(value.trim().trim_matches('"')); + return Ok(if data.is_absolute() { + data + } else { + root.join("Core").join(data) + } + .join("dbc_races/ChrRaces.dbc")); + } + Ok(root.join("Data/dbc_races/ChrRaces.dbc")) +} + +pub fn status(root: &Path) -> Result { + let bytes = fs::read(dbc_path(root)?)?; + let races = catalog(&bytes)?; + let conf = ConfFile::parse_bytes(&fs::read(root.join(CONF))?)?; + let configured = parse_ids(conf.get(KEY).unwrap_or(""))?; + let known: BTreeSet = races.iter().map(|race| race.id).collect(); + let unrecognized_race_ids: BTreeSet = configured.difference(&known).copied().collect(); + let disabled_race_ids = configured.intersection(&known).copied().collect(); + let dist = fs::read(root.join(format!("{CONF}.dist"))) + .ok() + .map(|bytes| ConfFile::parse_bytes(&bytes)) + .transpose()?; + let catalog_sha256 = hex::encode(Sha256::digest(&bytes)); + let saved_hash = conf + .get(CATALOG_KEY) + .unwrap_or("") + .trim() + .trim_matches('"') + .to_ascii_lowercase(); + let catalog_needs_review = !configured.is_empty() + && (saved_hash != catalog_sha256 || !unrecognized_race_ids.is_empty()); + Ok(Availability { + realm: crate::realms::state(root)?.active, + races, + disabled_race_ids, + catalog_sha256, + catalog_needs_review, + unrecognized_race_ids, + server_config_supported: dist + .is_some_and(|file| file.get(KEY).is_some() && file.get(CATALOG_KEY).is_some()) + && fs::read(crate::layout::executables(root).0).is_ok_and(|binary| { + [KEY, CATALOG_KEY] + .iter() + .all(|key| binary.windows(key.len()).any(|part| part == key.as_bytes())) + }), + }) +} + +pub fn save( + root: &Path, + meta: &Path, + expected_catalog: &str, + expected_realm: crate::realms::Mode, + disabled: &[u32], +) -> Result { + crate::update::ensure_recovered(meta)?; + fs::create_dir_all(root.join(".state"))?; + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(root.join(".state/control.lock"))?; + if !fs4::fs_std::FileExt::try_lock_exclusive(&lock)? { + return Err(invalid("Another server operation is in progress.")); + } + let observed = crate::process::observe(root, &crate::layout::read_ports(root)); + if observed.world.state != crate::process::ServiceState::Stopped + || crate::multiworld::is_running(root) + { + return Err(invalid( + "Stop the server before changing race availability.", + )); + } + let current = status(root)?; + if current.realm != expected_realm { + return Err(invalid( + "The selected realm changed. Reload race availability before saving.", + )); + } + if !current.server_config_supported { + return Err(invalid( + "Update the server to a build supporting individual race restrictions first.", + )); + } + if current.catalog_sha256 != expected_catalog { + return Err(invalid( + "The race catalog changed. Reload the race list before saving.", + )); + } + let value = disabled + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + let normalized = parse_disabled(&value, ¤t.races)?; + let value = normalized + .iter() + .map(u32::to_string) + .collect::>() + .join(","); + crate::modules::save_settings( + root, + meta, + "custom-races", + &BTreeMap::from([ + (KEY.into(), format!("\"{value}\"")), + ( + CATALOG_KEY.into(), + format!( + "\"{}\"", + if normalized.is_empty() { + "" + } else { + ¤t.catalog_sha256 + } + ), + ), + ]), + )?; + status(root) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn dbc() -> Vec { + let mut bytes = b"WDBC".to_vec(); + for value in [4u32, 69, 276, 11] { + bytes.extend(value.to_le_bytes()); + } + for id in [1u32, 82, 83, 97] { + let mut row = vec![0; 276]; + row[..4].copy_from_slice(&id.to_le_bytes()); + row[4..8].copy_from_slice(&12u32.to_le_bytes()); + row[56..60].copy_from_slice(&1u32.to_le_bytes()); + bytes.extend(row); + } + bytes.extend(b"\0Synthetic\0"); + bytes + } + + #[test] + fn catalog_preserves_ids_and_excludes_stock_and_nonplayable() { + let mut bytes = dbc(); + bytes[20 + 276 + 4] |= 1; + let races = catalog(&bytes).unwrap(); + assert_eq!( + races.iter().map(|race| race.id).collect::>(), + [83, 97] + ); + assert_eq!(races[0].name, "Synthetic"); + } + + #[test] + fn rejects_corrupt_layout_bounds_ids_and_names() { + for (offset, value) in [ + (4, 0), + (8, 68), + (12, 272), + (16, 100), + (20, 0), + (20 + 276, 1), + (20 + 56, 999), + ] { + let mut bytes = dbc(); + bytes[offset..offset + 4].copy_from_slice(&(value as u32).to_le_bytes()); + assert!(catalog(&bytes).is_err(), "offset {offset}"); + } + assert!(catalog(&dbc()[..19]).is_err()); + assert!(catalog(&dbc()[..100]).is_err()); + } + + #[test] + fn disabled_set_is_strict_and_atomic() { + let races = catalog(&dbc()).unwrap(); + assert_eq!( + parse_disabled("\"97,82\"", &races).unwrap(), + BTreeSet::from([82, 97]) + ); + assert!(parse_disabled("\"\"", &races).unwrap().is_empty()); + for value in [ + "82,1", "82,82", "82,250", "82,", "-82", "+82", "82,1-97", "82x", "0", + ] { + assert!(parse_disabled(value, &races).is_err(), "{value}"); + } + } + + #[test] + fn status_uses_current_config_and_requires_server_capability() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path(); + fs::create_dir_all(root.join("Data/dbc_races")).unwrap(); + fs::create_dir_all(root.join("Core/configs/modules")).unwrap(); + fs::write(root.join("Data/dbc_races/ChrRaces.dbc"), dbc()).unwrap(); + fs::write(root.join(CONF), format!("{KEY} = \"82\"\n")).unwrap(); + let before = status(root).unwrap(); + assert!(!before.server_config_supported); + assert_eq!(before.disabled_race_ids, BTreeSet::from([82])); + fs::write( + root.join(format!("{CONF}.dist")), + format!("{KEY} = \"\"\n{CATALOG_KEY} = \"\"\n"), + ) + .unwrap(); + assert!(!status(root).unwrap().server_config_supported); + fs::write( + root.join("Core/worldserver.exe"), + format!("{KEY} {CATALOG_KEY}"), + ) + .unwrap(); + let after = status(root).unwrap(); + assert!(after.server_config_supported); + assert_eq!(before.catalog_sha256, after.catalog_sha256); + } + + fn server(root: &Path) { + fs::create_dir_all(root.join("Data/dbc_races")).unwrap(); + fs::create_dir_all(root.join("Core/configs/modules")).unwrap(); + fs::write( + root.join("Core/worldserver.exe"), + format!("{KEY} {CATALOG_KEY}"), + ) + .unwrap(); + fs::create_dir_all(root.join("Settings")).unwrap(); + fs::write( + root.join("Settings/repack.json"), + r#"{"worldPort":0,"authPort":0,"mysqlPort":0,"raPort":0}"#, + ) + .unwrap(); + fs::write(root.join("Data/dbc_races/ChrRaces.dbc"), dbc()).unwrap(); + fs::write( + root.join(CONF), + format!("CoACustomRaces.Enable = 1\n{KEY} = \"82\"\nUser.Custom = 42\n"), + ) + .unwrap(); + fs::write( + root.join(format!("{CONF}.dist")), + format!("CoACustomRaces.Enable = 0\n{KEY} = \"\"\n{CATALOG_KEY} = \"\"\n"), + ) + .unwrap(); + fs::write( + root.join("Core/configs/modules/playerbots.conf"), + "AiPlayerbot.ExcludedBotRaces = \"1,83\"\nAiPlayerbot.CrashingBotRaces = \"97\"\n", + ) + .unwrap(); + } + + #[test] + fn saving_and_allow_all_preserve_user_settings_and_bot_exclusions() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let hash = status(&root).unwrap().catalog_sha256; + let bot_path = root.join("Core/configs/modules/playerbots.conf"); + let bot_before = fs::read(&bot_path).unwrap(); + let saved = save(&root, &meta, &hash, crate::realms::Mode::Coa, &[97, 83]).unwrap(); + assert_eq!(saved.disabled_race_ids, BTreeSet::from([83, 97])); + assert!(!saved.catalog_needs_review); + assert!(fs::read_to_string(root.join(CONF)).unwrap().contains(&hash)); + assert!(fs::read_to_string(root.join(CONF)) + .unwrap() + .contains("User.Custom = 42")); + assert_eq!(fs::read(&bot_path).unwrap(), bot_before); + assert!(save(&root, &meta, &hash, crate::realms::Mode::Coa, &[]) + .unwrap() + .disabled_race_ids + .is_empty()); + } + + #[test] + fn stale_catalog_and_invalid_lists_cannot_change_configuration() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let hash = status(&root).unwrap().catalog_sha256; + let before = fs::read(root.join(CONF)).unwrap(); + assert!(save(&root, &meta, "stale", crate::realms::Mode::Coa, &[83]).is_err()); + for ids in [&[82, 1][..], &[82, 82][..], &[250][..]] { + assert!(save(&root, &meta, &hash, crate::realms::Mode::Coa, ids).is_err()); + assert_eq!(fs::read(root.join(CONF)).unwrap(), before); + } + } + + #[test] + fn switching_realms_cannot_apply_a_draft_from_another_profile() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let loaded = status(&root).unwrap(); + let before = fs::read(root.join(CONF)).unwrap(); + let realm = crate::realms::RealmState { + active: crate::realms::Mode::Wildcard, + ..Default::default() + }; + fs::write( + root.join("Settings/realm-profile.json"), + serde_json::to_vec(&realm).unwrap(), + ) + .unwrap(); + assert!(save(&root, &meta, &loaded.catalog_sha256, loaded.realm, &[83]).is_err()); + assert_eq!(fs::read(root.join(CONF)).unwrap(), before); + } + + #[test] + fn concurrent_server_operations_cannot_change_policy() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + fs::create_dir_all(root.join(".state")).unwrap(); + let lock = fs::OpenOptions::new() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(root.join(".state/control.lock")) + .unwrap(); + assert!(fs4::fs_std::FileExt::try_lock_exclusive(&lock).unwrap()); + let loaded = status(&root).unwrap(); + let before = fs::read(root.join(CONF)).unwrap(); + assert!(save(&root, &meta, &loaded.catalog_sha256, loaded.realm, &[83]).is_err()); + assert_eq!(fs::read(root.join(CONF)).unwrap(), before); + } + + #[test] + fn relative_data_dir_uses_the_worldserver_working_directory() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + server(&root); + fs::write( + root.join("Core/configs/worldserver.conf"), + "DataDir = \"../Data\"\n", + ) + .unwrap(); + assert_eq!(status(&root).unwrap().races.len(), 3); + } + + #[test] + fn changed_catalog_requires_explicit_review_and_unknown_ids_are_not_valid_races() { + let temporary = tempfile::tempdir().unwrap(); + let root = temporary.path().join("server"); + let meta = temporary.path().join("metadata"); + server(&root); + let loaded = status(&root).unwrap(); + assert!(loaded.catalog_needs_review); + save(&root, &meta, &loaded.catalog_sha256, loaded.realm, &[82]).unwrap(); + assert!(!status(&root).unwrap().catalog_needs_review); + let mut changed = dbc(); + changed[20 + 276..24 + 276].copy_from_slice(&84u32.to_le_bytes()); + fs::write(root.join("Data/dbc_races/ChrRaces.dbc"), changed).unwrap(); + let updated = status(&root).unwrap(); + assert!(updated.catalog_needs_review); + assert_eq!(updated.unrecognized_race_ids, BTreeSet::from([82])); + assert!(!updated.races.iter().any(|race| race.id == 82)); + assert!(updated.disabled_race_ids.is_empty()); + let reviewed = save(&root, &meta, &updated.catalog_sha256, updated.realm, &[]).unwrap(); + assert!(!reviewed.catalog_needs_review); + assert!(reviewed.unrecognized_race_ids.is_empty()); + } +} diff --git a/docs/PER_RACE_EXISTING_FILTER_RESEARCH.md b/docs/PER_RACE_EXISTING_FILTER_RESEARCH.md new file mode 100644 index 0000000..b118994 --- /dev/null +++ b/docs/PER_RACE_EXISTING_FILTER_RESEARCH.md @@ -0,0 +1,165 @@ +# Existing Hide Races filter: internal research + +Inspected 2026-10-27. No installed game files were changed. No game, server, database or desktop +automation was used for this investigation. + +## Evidence + +The owner supplied screenshots of the creation screen and Hide Races panel. The previously +extracted `Interface/GlueXML/CharacterCreate.lua` from `patch-ZZZF.MPQ` contains this implementation. +SHA-256 of the extracted Lua: `8816ef9b6839652db60da6de7d2a6d09ceed1f80323ca5c8e55271a4d5aacac3`. +This identifies the inspected file, not proven runtime archive precedence. + +Line numbers below refer to the original LF-delimited file, ignoring carriage-return bytes. +The game source remains outside Git at `C:/games/source/per-race-evidence/`. + +| Function / lines | Confirmed behavior | +| --- | --- | +| `CharacterCreate_LayoutRaceButtons`, 138–185 | Reads hidden entries by `# + + + +

{t(saved ? "races.saved" : "races.restart")}

+

{t("races.clientPending")}

+

{t("races.existing")}

+ } + + ); +} diff --git a/tools/race_availability_poc.py b/tools/race_availability_poc.py new file mode 100644 index 0000000..5b8c3e7 --- /dev/null +++ b/tools/race_availability_poc.py @@ -0,0 +1,184 @@ +"""Offline experiment only; does not install patches or infer client load order.""" + +import argparse +import ctypes +import hashlib +import json +from pathlib import Path +import struct +import tempfile + + +MEMBER = r"DBFilesClient\ChrRaces.dbc" +STOCK = {1, 2, 3, 4, 5, 6, 7, 8, 10, 11} +LAYOUT = (69, 276) +ALGORITHM_VERSION = 1 +NAME_FIELDS = (*range(14, 30), *range(31, 47), *range(48, 64)) + + +def read_dbc(data): + if len(data) < 20: + raise ValueError("Truncated DBC header") + magic, count, fields, size, strings_size = struct.unpack_from("<4sIIII", data) + if magic != b"WDBC" or (fields, size) != LAYOUT or not count: + raise ValueError("Unsupported ChrRaces layout") + strings_start = 20 + count * size + if strings_start + strings_size != len(data) or not strings_size: + raise ValueError("Invalid DBC bounds") + strings = data[strings_start:] + if strings[-1] != 0: + raise ValueError("Invalid string block") + rows = {} + for index in range(count): + offset = 20 + index * size + race, flags = struct.unpack_from("= strings_size: + raise ValueError("Invalid race name offset") + names[field] = strings[name_offset:].split(b"\0", 1)[0].decode("utf-8", errors="strict") + name = names[14] + rows[race] = {"offset": offset, "flags": flags, "name": name} + return rows + + +def patch_dbc(original, disabled): + rows = read_dbc(original) + disabled = set(disabled) + if disabled & STOCK or not disabled <= rows.keys(): + raise ValueError("Only known custom RaceIDs can be disabled") + result = bytearray(original) + for race in disabled: + row = rows[race] + struct.pack_into("