feat(manager): configure per-race character creation

This commit is contained in:
Дмитрий committed 2026-10-27 17:24:50 +05:00
1 parent 925a81f4f2
commit 8ad06fd78a
24 files changed
+2058 -4

No files matched your search

+228
View File
@@ -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/<mode>.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/<file>` if
the master switch is enabled and that file exists, otherwise `DataDir/dbc/<file>`.
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.
@@ -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.
+32 -1
View File
@@ -111,6 +111,13 @@ pub fn playerbots_conf_paths(root: &Path) -> Vec<PathBuf> {
/// 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<bool> {
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);
}
}
}
+1
View File
@@ -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;
+14
View File
@@ -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)?;
}
+515
View File
@@ -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<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Availability {
pub realm: crate::realms::Mode,
pub races: Vec<Race>,
pub disabled_race_ids: BTreeSet<u32>,
pub catalog_sha256: String,
pub server_config_supported: bool,
pub catalog_needs_review: bool,
pub unrecognized_race_ids: BTreeSet<u32>,
}
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<Vec<Race>> {
let word = |offset: usize| -> Result<u32> {
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<BTreeSet<u32>> {
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::<u32>()
.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<BTreeSet<u32>> {
let disabled = parse_ids(value)?;
let known: BTreeSet<u32> = 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<PathBuf> {
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<Availability> {
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<u32> = races.iter().map(|race| race.id).collect();
let unrecognized_race_ids: BTreeSet<u32> = 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<Availability> {
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::<Vec<_>>()
.join(",");
let normalized = parse_disabled(&value, &current.races)?;
let value = normalized
.iter()
.map(u32::to_string)
.collect::<Vec<_>>()
.join(",");
crate::modules::save_settings(
root,
meta,
"custom-races",
&BTreeMap::from([
(KEY.into(), format!("\"{value}\"")),
(
CATALOG_KEY.into(),
format!(
"\"{}\"",
if normalized.is_empty() {
""
} else {
&current.catalog_sha256
}
),
),
]),
)?;
status(root)
}
#[cfg(test)]
mod tests {
use super::*;
fn dbc() -> Vec<u8> {
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::<Vec<_>>(),
[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());
}
}
+165
View File
@@ -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 `#<button index>`, displayed name or lowercase name; hides and relays out buttons by faction |
| Same function, 173–180 | If no button remains visible, shows every button and clears its hidden marker |
| `CharacterCreateEnumerateRaces`, 1864–1899 | Uses native name/English-name/enabled triples; button indices follow enumeration order |
| `CharacterCreate_Finish`, 2184–2212 | Delegates to `CoACharacterNames.CreateCharacter`; no filter-policy check is present in this function |
| `CHAR_CREATE_RACE_IDS` and `SetRace` wrapper, 3404–3459 | Maps English display names and repeated occurrence order to a `bonusRaceID` originally used for racial text |
| Filter persistence, 4603–4701 | Registers `coaHiddenRaces` and `coaHiddenRaces2`; writes with `SetCVar` and optional C_Config setters/save, all protected by `pcall` |
| `RaceEntries`, 4703 onward | Displayed RaceID comes from `bonusRaceID`, first name-map ID, or finally button index |
| Row click, 4844–4856 | Saves the displayed name and `#<button index>`; the displayed RaceID is not the saved key |
| `SetAllChecked`, 4977–4985 | Builds the same name/index set; does not apply a server policy |
| Save handler, 4988–4995 | Stores draft and relays out buttons; does not explicitly choose a permitted replacement race |
| Row refresh, 5018–5041 | Stores `info.id` (button index) in `row.raceId`, while the label displays `info.raceID` |
| Click wrapper, 5056–5069 | Redirects a click on a hidden button to the first visible one |
The name-to-ID table is hardcoded and extended repeatedly later in the Lua. Examples of paired
names: Pandaren `{29,20}`, Murloc `{30,31}`, Earthen `{68,69}`, Haranir `{71,70}`,
Centaur `{75,102}` and Drakonid `{100,98}`. Name localization and enumeration-order changes
need validation; this table is not a universal verified RaceID API.
## Offline reproduction
An isolated harness executed the actual extracted layout, persistence and click-wrapper functions
using the already installed `lupa.lua51` runtime. Buttons, CVar storage, faction lookup and filter
button creation were mocked. No render, native race API, packet send, account or database was used.
Harness: `C:/games/source/per-race-evidence/probe_existing_filter.py`.
Machine-readable results: `C:/games/source/per-race-evidence/existing-filter-probe.json`.
All nine assertions were confirmed:
1. Saving one Pandaren by name hides both same-named Pandaren buttons.
2. A saved index plus saved name can hide two different races after reordering.
3. Hiding all buttons makes the layout show all buttons again.
4. Saving 128 synthetic index entries persists at most 480 characters; a fresh runtime recovered
only 115 of those indices. The exact losses depend on the input and sort order.
5. Saving/hiding the currently selected button leaves the harness's selection unchanged.
6. Clicking a hidden button redirects to the first visible one.
7. The same preference values apply across independent Glue runtimes; no realm identifier is read.
8. Saving `#82` does not hide Sethrak at button index 2, even when the button's `bonusRaceID`
is 82. Current serialization cannot simply be given real IDs instead of indices.
9. If persistent setters fail, saving still updates the in-memory list and reports no failure;
a fresh runtime loses that list. Protected calls suppress the errors.
The initial harness run lacked a stub for filter-button creation and failed six checks before
their assertions; adding that UI stub allowed the original assertions to run and pass. The later
expanded run passed all nine assertions. This correction
did not modify the inspected game Lua. These results are offline behavior checks, not live acceptance.
## Persistence and identity cross-check
Read-only searches of both the primary client and isolated copy found no `coaHiddenRaces` or
`coaHiddenRaces2` entries in their `WTF/Config.wtf` or `WTF/Custom/GlueConfig.json`, or in the
searched text configuration files. There is therefore no observed on-disk value establishing
where this installation persists the list. The presence of `SetCVar` calls alone does not prove
that an arbitrary registered CVar is archived. C_Config setters/save are optional and their
failures are suppressed. Physical persistence must be verified later by a controlled save/exit
and file comparison, when the owner allows game testing. The earlier Config.wtf suggestion
remains a hypothesis, not an established path.
Listfile-guided scanning of Lua members in patch-B, patch-T and patch-ZZZF found the filter's
setter calls but no implementation of these C_Config persistence methods. This does not establish
their absence at runtime: native code or another archive may provide them. No native client code
was executed or patched to investigate this.
The final `CHAR_CREATE_RACE_IDS` map was reconstructed by executing its literal declarations and
subsequent assignments in Lua 5.1. All 73 inspected ChrRaces rows whose NOT_PLAYABLE bit is clear
are represented by their English name and actual ID in that map. The comparison includes final
overrides, rather than only the initial table. Result:
`C:/games/source/per-race-evidence/race-filter-id-map-audit.json`.
This is good evidence that the map can support this version's UI integration. It is not proof
that each runtime button is assigned the right ID: repeated names depend on occurrence order,
the engine may enumerate a different set/order, and localized labels may not match the English
keys. `bonusRaceID` remains a candidate identity, not an authoritative native lookup.
## Suitability for CSM
The existing layout and UI are useful foundations. Directly replacing `coaHiddenRaces` with owner
restrictions is unsuitable: the player can clear the list, names can collide, indices can shift,
serialization truncates and the preferences are not realm-scoped. It would also overwrite the
player's own display choices. A local filter cannot enforce server creation policy.
Recommended integration to investigate next:
- Keep personal Hide Races preferences independent from the server-managed disabled RaceID set.
- Resolve actual RaceID per button from validated version-specific client data and faction/identity.
Never use a button-index fallback as an authoritative RaceID. Ambiguous mapping is unsupported,
not permission to hide another race with the same name.
- Compute visibility from personal preference OR the server restriction. Check All may clear
personal preferences but must not remove the server restriction.
- Preserve stock-race behavior and the existing layout, class-selection and Esteria wrappers.
The empty-layout fallback must not reveal a server-prohibited race.
- Validate selected race before advancing/creating, including after save, randomization, sex
changes and native selection resets. Do not renumber native race indices.
- Reconcile the selected local realm before PLAY with the client closed. Do not guess public-server
policy or mutate the player's saved preference when switching realms.
- Independently enforce creation on the server and in new-bot factories, preserving existing
character login/import and existing-bot admission.
One possible delivery is a small GlueXML extension in the single managed overlay, reusing existing
layout entry points while supplying a separately generated policy table. This is a proposal, not
an existing API or a selected filename. It still requires proven loading order and live validation.
An alternative CVar-based channel needs its own persistence and truncation checks and must not
reuse the personal hidden-list keys.
## Decision
Prioritize investigation of the existing GlueXML mechanism as the client integration candidate.
Do not yet commit to a DBC-only overlay, and do not declare the DBC hypothesis failed: it has not
been live-tested. No gameplay/client support claim follows from these screenshots or mock tests.
The next live acceptance must establish correct real RaceID mapping, one disabled race, independent
faction variants, selected-race replacement, Check All resistance to owner-policy removal, ordinary
race creation, existing-character login and profile switching. Interactive/database tests remain
deferred at the owner's request.
## User-operated visibility experiment and disabled-row prototype
The owner subsequently authorized a temporary patch in the isolated client. The first probe added
only a real-ID predicate for Sethrak 82, Gnoll 83 and Saberon 97 to the existing layout. A single
`Data/patch-ZZZZZ.MPQ` contained the modified CharacterCreate.lua; original archives and DBCs
were unchanged. The owner reported the races disappeared and supplied screenshots consistent
with that result. This is evidence that this particular Lua overlay affects this installation's
creation screen, not proof of universal MPQ precedence or completed creation/login tests.
The owner then selected the UI behavior: hide owner-prohibited races on the creation screen;
show them grey, unchecked and disabled in Hide Races, with a server-disabled label. Personal
preferences remain independent, including when the owner later re-enables the race.
`tools/race_visibility_lua_poc.py` now generates a version-pinned second laboratory probe. It
accepts only the exact inspected Lua fingerprint and the three previously tested RaceIDs.
Unknown identity is not interpreted as a button index. It guards disabled row clicks, restores
enabled state when a scroll row is reused, and prevents the empty-layout fallback from revealing
an owner-prohibited race. The disabled label has EN/RU/DE/FR/ES/ZH translations.
Eleven tests passed under Lua 5.1, covering syntax, disabled row state, personal preference,
scroll-row reuse, ambiguous identity, Check All, direct disabled-row click, six translations,
unsupported input rejection, empty-layout fallback and lifting restrictions.
Probe v2 was MPQ-roundtrip verified and installed only after observing that the isolated client
was closed. The previous owned probe's SHA-256 was checked before replacement, the existing
client patch lock was held, and the prior bytes were preserved outside the client. Its evidence
is `C:/games/source/per-race-evidence/lua-visibility-probe-v2/report.json`.
The primary client, all databases and all server processes were untouched.
This prototype still contains a fixed laboratory restriction set, not the future server-driven
policy. The owner confirmed grey, unchecked rows for the three probe races in game and supplied
screenshots. Selected-race validation, realm policy
reconciliation, source update handling, server enforcement and new-bot integration remain work.
+131
View File
@@ -0,0 +1,131 @@
# Per-race toggles: internal design and implementation status
This is an internal specification and work-in-progress record, not a released user guide.
Current verdict: **PARTIAL**. Client hiding and disabled rows were confirmed by the owner;
server policy, new-bot integration and Manager controls are now implemented in feature branches.
Native build qualification and server gameplay acceptance are still pending.
See [audit](../PER_RACE_TOGGLES_AUDIT.md) and [verification record](PER_RACE_TOGGLES_TESTS.md).
## Verified data and outstanding client decision
The local manifest identifies CoA Custom 1.9.2. The candidate source archive is `patch-ZZZF.MPQ`.
Its ChrRaces has WDBC layout 95/69/276/2428 (records/fields/record bytes/string bytes).
Its DBC SHA-256 is `b78635b08bf0bd63d2898fce97c1fb162c8a9c41632703172eefa88bae79061f`.
Observed IDs include Sethrak 82, Gnoll 83 and Saberon 97, all initially Flags 12.
The experiment sets `originalFlags | 0x01` only for selected IDs. Empty selection preserves
every original byte. Each run reads the explicitly selected source MPQ anew; it never consumes
the previous generated DBC. Re-enabling must restore the original record, including an original
NOT_PLAYABLE bit if present; it must not make an originally nonplayable race playable.
These observations do not qualify a client version. Actual archive precedence, menu behavior,
other-race creation and existing-character login are untested. No managed overlay filename has
been chosen. The tool does not infer precedence from filename sorting.
## Intended integration
```mermaid
flowchart TD
P[Existing realm configuration snapshot] --> S[Server creation policy]
P --> G[Availability generation, after client qualification]
O[Original effective client DBC] --> G
G --> V[Validate bytes and MPQ roundtrip]
V --> T[Existing client lock and transaction journal]
T --> M[One managed overlay]
S --> H[Normal account creation hook]
S --> C[Companions new-character creation]
S --> Q[SQUID new-character creation]
```
The new server key is `CoACustomRaces.DisabledRaceIds` in the existing module config.
It requires a newly built server; no installed server binary has been replaced. Empty or missing
key retains the previous master-switch behavior.
Validate the entire set before applying it; reject malformed, unknown and stock IDs without a
partial policy. Validate identity against effective server/client data, not numeric ranges alone.
Creation policy is independent of login and import. It must not modify existing characters,
appearance, items, spells, progress or Portable Characters identity. Bot creation additionally
obeys master switches, module switches, user exclusions and crashing-race restrictions. Existing
bot admission must not inherit a new-character restriction accidentally.
Use current module configuration, additive `.dist` merge and `Settings/realm-profiles/` snapshots.
Do not introduce another authoritative policy store. The future client record is provenance and
ownership metadata, not a replacement for the realm configuration.
Local PLAY must reconcile the selected profile before launch while the client is closed, using
the same `.coa-races.lock` as package operations. Shared clients have one currently installed
overlay; remote policies cannot be guessed. Unknown formats or conflicting ownership must fail
with a truthful status. Client and server application status must be separate in the UI.
Client repair/update must invalidate the previous overlay when the original effective DBC
changes and regenerate from the new original only after compatibility checks. Empty restrictions
must remove only the verified CSM-owned overlay. Actual install/recovery/update behavior is
not implemented by the offline tool.
## Offline tool
`tools/race_availability_poc.py` extracts with an explicitly supplied, existing Unicode StormLib
DLL. That DLL is a laboratory dependency only; no Manager/user dependency has been selected.
The tool writes `original.dbc`, `generated.dbc`, `experiment.MPQ` and `report.json` to a new output
directory after successful verification. It never installs an overlay, edits an input MPQ or
launches a process. The archive contains one game payload plus StormLib's internal listfile.
The fingerprint includes original DBC hash, sorted unique disabled IDs, algorithm version and
recorded client profile. It supports reproducibility checks; runtime caching is not implemented.
An empty selection still produces an offline test artifact so byte equality can be checked;
production empty-set removal is a separate, unimplemented requirement.
No game files or DLLs are checked into Git. Tests generate synthetic DBC/MPQ fixtures locally.
No export/distribution feature, release-channel change or publication is included.
## Remaining acceptance gate
The owner subsequently confirmed the Lua visibility and disabled-row probes through screenshots.
The selected client approach reuses the existing GlueXML filter rather than changing DBC flags.
This does not establish whether the earlier DBC-bit hypothesis works.
The owner deferred database/server testing while another server runs bot tests.
Do not launch the game, servers or MySQL or capture/control the screen without renewed authorization.
Use the owner-confirmed GlueXML approach for the next client integration stage. It preserves
the original DBC. The DBC-bit approach has not been declared incompatible; it was not live-tested.
## Server controls implemented in the feature branches
Manager exposes a versioned catalog read from the server's `DataDir/dbc_races/ChrRaces.dbc`, with
strict WDBC layout/ID/name validation and stock/nonplayable rows excluded. The UI supports search,
counts, per-race allow checkboxes, Allow all, explicit Save/Discard and navigation guards. New
strings exist in all six Manager languages.
Saving requires a stopped server, no unfinished update, the existing control lock, an unchanged
catalog SHA-256 and the same selected realm as the loaded draft.
It uses the normal module backup/config writer. Servers whose distributed configuration does
not declare the new key cannot be configured through this control. The executable is also checked
for the key, following existing realm capability detection. The UI reports saved settings
and restart requirements, not a verified running-server Applied status. Template detection does
not independently attest the identity of a manually modified server executable.
Saved restrictions include the source catalog SHA-256. A changed catalog or orphaned IDs requires
explicit review and save; simply opening the panel does not migrate settings. Core and the offline
bot factory reject a mismatched bound catalog before creating characters. Empty hashes remain
supported for manually authored legacy configurations.
The core module stages a whole policy, validates configured IDs against loaded server race data,
and applies it atomically on startup/reload. Invalid input retains the previous policy; on an
initial invalid configuration, new custom creation remains disabled. Stock creation remains
available. The normal account hook remains in place and import/login paths are unchanged.
CoA Companions' common clone entry calls the account creation hook before inserting a new bot;
its selection also preserves explicit crashing exclusions. SQUID's random factory calls the
same hook while constructing candidate races, without adding owner restrictions to existing-bot
admission lists. The offline Companions factory reads configuration and filters prohibited
templates before creating accounts or generating insert SQL. No SQL was executed for verification.
The old Manager synchronization now preserves explicit user race exclusions. It only replaces
lists carrying its old ownership comment with an exact known generated value. Crashing-race
settings are never rewritten. BotsEnable is read directly from the Custom Races configuration
when available rather than inferred from unrelated exclusion choices.
Automatic production client synchronization and client update invalidation remain unimplemented.
Server-side catalog binding is implemented. The fixed three-race test overlay is not automatically changed
by these new server controls. This limitation is explicitly visible in the Manager UI.
+92
View File
@@ -0,0 +1,92 @@
# Per-race toggles: internal verification record
Date: 2026-10-27. Verdict: **PARTIAL**. Scope: source implementation, native builds and offline verification; live acceptance remains pending.
Latest source implementation checks additionally include:
- Manager catalog/configuration tests: 10 passed, including catalog binding, unknown-ID review, stale/invalid input rejection,
Allow all, preserving unknown configuration keys and leaving bot exclusions unchanged.
- Focused Manager race regression selection: 34 passed, 3 ignored because they require release
archives or another writable volume. The three ignored tests are not counted as passes.
- Frontend TypeScript/Vite build: passed after correcting the new component's error rendering.
- Existing i18n and module-settings checks: passed; new keys exist in all six languages.
- Core production-module harness: passed with synthetic Config/DBC stubs through verify_all.
- Companions production selector harness and five offline-factory policy tests: passed without
running a MySQL client or server.
- Complete native core build with staged Companions and SQUID: passed in the dedicated
`build-utf8` directory. Final source/build/harness verification passed in
`C:/games/source/per-race-core/.cache/verify-all/20261027-112640/report.json`.
Unit and gameplay stages were skipped. No installed binaries or services were changed.
- Rust Clippy with warnings denied: passed for coa-core.
- Final Tauri debug application build: passed after the catalog-review changes. Artifact:
`C:/games/source/per-race-manager/target/debug/coa-server-manager.exe`.
Frontend bundle-size and localized linker-output warnings were emitted; neither failed the build.
The first native configure lacked an OpenSSL root. A subsequent build was deliberately stopped
when its MSVC dependency prefix was found misencoded. The replacement uses the existing dynamic
OpenSSL/MySQL development libraries and a fresh UTF-8 environment. These earlier verify_all runs
ended FAILED and are not successful build evidence.
## Executed automated checks
Run from the Manager feature checkout:
```powershell
$env:COA_TEST_STORMLIB = '<absolute path to an existing Unicode StormLib DLL>'
python -B -m unittest discover -s tools -p 'test_race_availability_*.py' -v
```
The recorded run used the bundled repack Python at
`C:/games/CoA Server 2/Runtime/python/python.exe` and the existing DLL at
`C:/Users/Dion/AppData/Local/Programs/Python/Python311/Lib/site-packages/pympq/StormLib.dll`.
No database or game process is used. Without the explicit DLL environment variable, MPQ tests
are skipped; that is not equivalent to the recorded full pass.
| Group | Result | Actual scope |
| --- | --- | --- |
| DBC and fingerprint | PASS, 17 tests | Exact changed bytes; 1/5/50 IDs; other bits; original restoration; duplicate normalization; stock/unknown IDs; duplicate/invalid table IDs; corrupt/truncated/unknown layouts; localized name bounds and UTF-8; source/profile/set fingerprint inputs |
| Synthetic MPQ | PASS, 8 tests | 1/50 IDs produce one archive; one game payload; extraction equality; identical regenerated DBC/fingerprint; source unchanged; existing output preserved; packaging/readback/read/input errors publish no result; empty set preserves bytes |
| Total | PASS, 25 tests, zero skipped | Offline files only |
| Real three-race DBC/MPQ | PASS | Sethrak 82, Gnoll 83, Saberon 97; only Flags 12 → 13; one MPQ; re-extracted DBC equals expected; original MPQ hash unchanged |
| Existing filter behavior | CONFIRMED, 9 offline assertions | Original extracted Lua functions under Lua 5.1 with UI/CVar mocks; see existing-filter research |
| English name/ID map vs original DBC | PASS, 73 rows with NOT_PLAYABLE clear | Every inspected ID appears in the final literal Lua name map; runtime button identity remains unverified |
| Managed disabled-row Lua probe | PASS, 11 tests | Lua 5.1 syntax; grey/unchecked/disabled rows; personal choices; scroll reuse; unknown identity; Check All; click guard; six languages; input rejection; layout fallback; lifting restrictions |
| First Lua visibility probe | USER-OBSERVED | Owner reported Sethrak/Gnoll/Saberon absent and supplied screenshots; creation, login and grey rows not tested |
An initial new MPQ assertion expected one block-table entry and failed for both cardinalities.
Inspection showed StormLib also writes `(listfile)`. The corrected check requires two entries
and verifies the listfile names exactly one game file. All 25 tests then passed. This was a test
assumption correction, not a change to the functional requirement of one overlay.
Local evidence for real game files stays outside Git:
`C:/games/source/per-race-evidence/multiple-races-offline/report.json`.
Original DBC hash: `b78635b08bf0bd63d2898fce97c1fb162c8a9c41632703172eefa88bae79061f`.
Generated three-race DBC hash: `243fb36837b76ebd6943da574cc095be548b4530be7f06487debea6703db10d3`.
## Unexecuted checks
| Scope | Status | Reason |
| --- | --- | --- |
| Effective running-client DBC and general MPQ load order | NOT RUN | First Lua probe visibly affected this one installation, but effective DBC/general precedence remain unverified |
| LIVE-01 one disabled race | NOT RUN | Requires client and isolated server |
| LIVE-02 multiple disabled races and re-enable | NOT RUN | Requires client and isolated server |
| LIVE-03 existing character data/login | NOT RUN | No database/login testing performed |
| LIVE-04 bot generation, both systems separately | NOT RUN | Other bot tests are in progress; MySQL must remain untouched |
| LIVE-05 Portable Characters | NOT RUN | Requires disposable realm/database setup |
| LIVE-06 client repair/update | NOT RUN | Runtime overlay integration not implemented |
| LIVE-07 shared client | NOT RUN | Runtime profile reconciliation not implemented |
| Server config/parser/hook tests | PASS, offline | Actual module compiled with Config/DBC/crypto stubs; native build also passed |
| Client transaction crash/recovery matrix | NOT RUN | Offline result staging is not client install/recovery |
| Manager visual interaction | NOT RUN | Owner requested no screen capture/control; translation checks and frontend build passed |
| Full Rust workspace tests | NOT RUN | Focused race tests and native Manager build used instead |
| C++ build and verify_all | PASS, source/build/harness | No server/database process started; gameplay excluded |
| Release packaging and upgrades | NOT RUN | No release or runtime implementation prepared |
The copied client initially displayed a missing-critical-files message and crashed on exit.
The fault address maps to AMD's AMDXN32.DLL; root cause is unresolved. No experimental overlay
was installed at the time of that crash. Later Lua probes were installed only in the isolated copy,
and the owner confirmed hidden races and disabled grey rows. Creation could not be checked.
Neither observation is an availability live-test pass.
Existing character data and original game archives were not changed. No SQL was executed.
The feature must not be declared READY on these results.
+28
View File
@@ -1237,6 +1237,32 @@ async fn custom_races_status(
.await
}
#[tauri::command]
async fn race_availability(
state: State<'_, AppState>,
id: String,
) -> std::result::Result<coa_core::race_availability::Availability, UiError> {
let root = path_of(&state, &id)?;
blocking(move || coa_core::race_availability::status(&root)).await
}
#[tauri::command]
async fn race_availability_save(
state: State<'_, AppState>,
id: String,
catalog_sha256: String,
realm: coa_core::realms::Mode,
disabled_race_ids: Vec<u32>,
) -> std::result::Result<coa_core::race_availability::Availability, UiError> {
let root = path_of(&state, &id)?;
let _guard = BusyGuard::acquire(&state, &id)?;
blocking(move || {
let (dir, _) = install_meta(&root)?;
coa_core::race_availability::save(&root, &dir, &catalog_sha256, realm, &disabled_race_ids)
})
.await
}
#[tauri::command]
async fn custom_races_install(
app: AppHandle,
@@ -2847,6 +2873,8 @@ pub fn run() {
console_risk,
console_command,
custom_races_status,
race_availability,
race_availability_save,
custom_races_install,
custom_races_uninstall,
custom_races_toggle_client,
+13
View File
@@ -2,6 +2,19 @@ import type en from "./en";
/** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */
const de: Partial<Record<keyof typeof en, string>> = {
"races.unknownIds": "Nicht mehr im Katalog vorhandene Völker-IDs: {ids}. Beim Speichern werden diese alten Einschränkungen entfernt.",
"races.reviewCatalog": "Der Völkerkatalog wurde geändert oder noch nicht geprüft. Prüfe die Liste und speichere sie erneut.",
"races.available": "Verfügbare Völker",
"races.count": "{allowed} erlaubt · {disabled} deaktiviert",
"races.allowCreation": "Neue Charaktere erlauben",
"races.allowAll": "Alle erlauben",
"races.alliance": "Allianz",
"races.horde": "Horde",
"races.updateServer": "Aktualisiere den Server für individuelle Völkerbeschränkungen.",
"races.restart": "Stoppe den Server vor dem Speichern und starte ihn danach erneut.",
"races.saved": "Serverkonfiguration gespeichert. Starte den Server zum Anwenden.",
"races.clientPending": "Client: Die automatische Synchronisierung ist noch nicht eingerichtet.",
"races.existing": "Bestehende Charaktere behalten ihr Volk und ihre Daten.",
"mod.switching": "Änderungen werden angewendet… Bitte warten.",
"portable.experimental": "Experimentelle Funktion: Portable Charaktere befinden sich noch in aktiver Entwicklung. Fehler können auftreten.",
"remote.clientReady": "Dein Client ist bereit. Speichere die Hostadresse und klicke auf Spielen.",
+13
View File
@@ -1,5 +1,18 @@
/** English is the source of truth: every key must exist here; other locales fall back to it. */
const en = {
"races.unknownIds": "RaceIDs no longer in this catalog: {ids}. Saving the reviewed list removes these old restrictions.",
"races.reviewCatalog": "The race catalog changed or has not been verified. Review the list and save it before applying restrictions.",
"races.available": "Available races",
"races.count": "{allowed} allowed · {disabled} disabled",
"races.allowCreation": "Allow new characters",
"races.allowAll": "Allow all",
"races.alliance": "Alliance",
"races.horde": "Horde",
"races.updateServer": "Update the server to a build supporting individual race restrictions.",
"races.restart": "Stop the server before saving; start it again to apply the policy.",
"races.saved": "Server configuration saved. Start the server to apply it.",
"races.clientPending": "Client visibility: automatic synchronization is not configured yet.",
"races.existing": "Existing characters keep their race and data.",
"mod.switching": "Applying changes… Please wait.",
"remote.clientReady": "Your client is ready. Save the host’s address, then press Play.",
"remote.clientIntro": "Choose an existing game client or download one here. No local server is needed.",
+13
View File
@@ -2,6 +2,19 @@ import type en from "./en";
/** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */
const es: Partial<Record<keyof typeof en, string>> = {
"races.unknownIds": "RaceID que ya no están en el catálogo: {ids}. Al guardar la lista revisada se eliminan estas restricciones antiguas.",
"races.reviewCatalog": "El catálogo de razas ha cambiado o aún no está verificado. Revisa la lista y guárdala.",
"races.available": "Razas disponibles",
"races.count": "{allowed} permitidas · {disabled} desactivadas",
"races.allowCreation": "Permitir nuevos personajes",
"races.allowAll": "Permitir todas",
"races.alliance": "Alianza",
"races.horde": "Horda",
"races.updateServer": "Actualiza el servidor para restringir razas individualmente.",
"races.restart": "Detén el servidor antes de guardar y vuelve a iniciarlo después.",
"races.saved": "Configuración guardada. Inicia el servidor para aplicarla.",
"races.clientPending": "Cliente: la sincronización automática aún no está configurada.",
"races.existing": "Los personajes existentes conservan su raza y sus datos.",
"mod.switching": "Aplicando cambios… Espera un momento.",
"portable.experimental": "Función experimental: los personajes portátiles siguen en desarrollo activo. Pueden producirse errores.",
"remote.clientReady": "Tu cliente está listo. Guarda la dirección del anfitrión y pulsa Jugar.",
+13
View File
@@ -2,6 +2,19 @@ import type en from "./en";
/** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */
const fr: Partial<Record<keyof typeof en, string>> = {
"races.unknownIds": "RaceID absents de ce catalogue : {ids}. Sauvegarder la liste vérifiée supprime ces anciennes restrictions.",
"races.reviewCatalog": "Le catalogue des races a changé ou n’a pas été vérifié. Vérifiez la liste et sauvegardez-la.",
"races.available": "Races disponibles",
"races.count": "{allowed} autorisées · {disabled} désactivées",
"races.allowCreation": "Autoriser les nouveaux personnages",
"races.allowAll": "Tout autoriser",
"races.alliance": "Alliance",
"races.horde": "Horde",
"races.updateServer": "Mettez le serveur à jour pour restreindre les races individuellement.",
"races.restart": "Arrêtez le serveur avant de sauvegarder, puis redémarrez-le.",
"races.saved": "Configuration sauvegardée. Démarrez le serveur pour l’appliquer.",
"races.clientPending": "Client : la synchronisation automatique n’est pas encore configurée.",
"races.existing": "Les personnages existants conservent leur race et leurs données.",
"mod.switching": "Application des modifications… Veuillez patienter.",
"portable.experimental": "Fonction expérimentale : les personnages portables sont encore en développement actif. Des bugs peuvent survenir.",
"remote.clientReady": "Votre client est prêt. Enregistrez l’adresse de l’hôte, puis cliquez sur Jouer.",
+13
View File
@@ -2,6 +2,19 @@ import type en from "./en";
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
const ru: Partial<Record<keyof typeof en, string>> = {
"races.unknownIds": "RaceID, которых больше нет в каталоге: {ids}. Сохранение проверенного списка удалит эти старые ограничения.",
"races.reviewCatalog": "Каталог рас изменился или ещё не проверен. Проверьте список и сохраните его перед применением ограничений.",
"races.available": "Доступные расы",
"races.count": "Разрешено: {allowed} · Отключено: {disabled}",
"races.allowCreation": "Разрешить создание",
"races.allowAll": "Разрешить все",
"races.alliance": "Альянс",
"races.horde": "Орда",
"races.updateServer": "Обновите сервер для поддержки ограничений по расам.",
"races.restart": "Остановите сервер перед сохранением и запустите снова для применения.",
"races.saved": "Настройки сервера сохранены. Запустите сервер для применения.",
"races.clientPending": "Клиент: автоматическая синхронизация пока не настроена.",
"races.existing": "Существующие персонажи сохраняют расу и данные.",
"mod.switching": "Применяем изменения… Подождите.",
"portable.experimental": "Экспериментальная функция: портативные персонажи ещё в активной разработке. Возможны ошибки.",
"remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».",
+13
View File
@@ -1,5 +1,18 @@
/** Simplified Chinese localization (zh-CN) */
const zh = {
"races.unknownIds": "此目录中已不存在的种族 ID:{ids}。保存检查后的列表将移除这些旧限制。",
"races.reviewCatalog": "种族目录已更改或尚未验证。请检查列表并保存后再应用限制。",
"races.available": "可用种族",
"races.count": "已允许 {allowed} · 已禁用 {disabled}",
"races.allowCreation": "允许创建新角色",
"races.allowAll": "允许全部",
"races.alliance": "联盟",
"races.horde": "部落",
"races.updateServer": "请更新服务器以支持单独限制种族。",
"races.restart": "保存前请停止服务器,然后重新启动以应用设置。",
"races.saved": "服务器配置已保存。请启动服务器以应用。",
"races.clientPending": "客户端:尚未配置自动同步。",
"races.existing": "现有角色保留其种族和数据。",
"mod.switching": "正在应用更改…请稍候。",
"portable.experimental": "实验性功能:便携角色仍在积极开发中,可能存在错误。",
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
+13
View File
@@ -393,6 +393,16 @@ export interface ClientDownloadCheck {
export type ClientPatchState = "not-installed" | "enabled" | "disabled" | "partially-installed";
export interface RaceAvailability {
realm: RealmMode;
races: { id: number; name: string; faction: "alliance" | "horde" | null }[];
disabledRaceIds: number[];
catalogSha256: string;
serverConfigSupported: boolean;
catalogNeedsReview: boolean;
unrecognizedRaceIds: number[];
}
export interface CustomRacesClientStatus {
version: string | null;
state: ClientPatchState;
@@ -553,6 +563,9 @@ export const api = {
clientDownload: (id: string, parent: string) => invoke<ClientInfo>("client_download", { id, parent }),
clientCancel: () => invoke<void>("client_cancel"),
customRacesStatus: (id: string) => invoke<CustomRacesClientStatus | null>("custom_races_status", { id }),
raceAvailability: (id: string) => invoke<RaceAvailability>("race_availability", { id }),
raceAvailabilitySave: (id: string, catalogSha256: string, realm: RealmMode, disabledRaceIds: number[]) =>
invoke<RaceAvailability>("race_availability_save", { id, catalogSha256, realm, disabledRaceIds }),
customRacesUninstall: (id: string) => invoke<void>("custom_races_uninstall", { id }),
customRacesInstall: (id: string) => invoke<void>("custom_races_install", { id }),
customRacesToggleClient: (id: string, enabled: boolean) => invoke<void>("custom_races_toggle_client", { id, enabled }),
+13 -3
View File
@@ -8,6 +8,7 @@ import { Card } from "@/components/ui/card";
import { useHuman, useI18n, useSchemaText, type Key } from "@/i18n";
import { auctionWeightKey, canonicalModuleValue, moduleEnableKey, moduleField, retiredModuleKeys } from "@/lib/moduleSettings";
import { AuctionTypes } from "@/screens/AuctionTypes";
import { RaceAvailabilitySettings } from "@/screens/RaceAvailabilitySettings";
import type { UnsavedChangesGuard } from "@/lib/unsavedGuard";
/** The GitHub mark (lucide dropped its brand icons). */
@@ -104,11 +105,17 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
}
}, [serverId]);
const [guard, setGuard] = useState<UnsavedChangesGuard | null>(null);
const [racesGuard, setRacesGuard] = useState<UnsavedChangesGuard | null>(null);
useEffect(() => {
onRegisterGuard?.(guard);
const guards = [guard, racesGuard].filter((item): item is UnsavedChangesGuard => item !== null);
onRegisterGuard?.(guards.length ? {
isDirty: () => guards.some(item => item.isDirty()),
save: async () => { for (const item of guards) if (item.isDirty() && !await item.save()) return false; return true; },
discard: () => guards.forEach(item => item.discard()),
} : null);
return () => onRegisterGuard?.(null);
}, [guard, onRegisterGuard]);
}, [guard, racesGuard, onRegisterGuard]);
const load = useCallback(async () => {
try {
@@ -317,6 +324,9 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
<SettingsPanel key={selected.id} className="sm:col-span-2">
<h2 className="font-semibold">{t("mod.settingsOf", { name: selected.name })}</h2>
<ModuleSettings serverId={serverId} module={selected} onSaved={() => setChanged(true)} onRegisterGuard={setGuard} />
{selected.id === "custom-races" && <RaceAvailabilitySettings serverId={serverId}
blocked={racesInstalling || toggling !== null} onSaved={() => setChanged(true)}
onRegisterGuard={setRacesGuard} />}
</SettingsPanel>
)}
</Fragment>
@@ -409,7 +419,7 @@ function ModuleSettings({ serverId, module, onSaved, onRegisterGuard }: { server
else next[key] = value;
return next;
});
const available = items?.filter(s => s.key !== moduleEnableKey(module.id) && !retiredModuleKeys.has(s.key)) ?? [];
const available = items?.filter(s => s.key !== moduleEnableKey(module.id) && !["CoACustomRaces.DisabledRaceIds", "CoACustomRaces.DisabledRaceCatalogSha256"].includes(s.key) && !retiredModuleKeys.has(s.key)) ?? [];
const advancedCount = available.filter(s => !auctionWeightKey(s.key) && (!moduleField(s.key, locale, s.field) || moduleField(s.key, locale, s.field)?.advanced)).length;
const visible = available.filter(s => {
if (module.id === "ah-bot" && auctionWeightKey(s.key)) return false;
+120
View File
@@ -0,0 +1,120 @@
import { useEffect, useState } from "react";
import { api, asUiError, type RaceAvailability, type UiError } from "@/lib/api";
import { Button } from "@/components/ui/button";
import { useHuman, useI18n } from "@/i18n";
import type { UnsavedChangesGuard } from "@/lib/unsavedGuard";
export function RaceAvailabilitySettings({ serverId, blocked, onSaved, onRegisterGuard }: {
serverId: string;
blocked: boolean;
onSaved: () => void;
onRegisterGuard: (guard: UnsavedChangesGuard | null) => void;
}) {
const { t } = useI18n();
const human = useHuman();
const [data, setData] = useState<RaceAvailability | null>(null);
const [disabled, setDisabled] = useState<number[]>([]);
const [query, setQuery] = useState("");
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<UiError | null>(null);
const canonical = (ids: number[]) => [...ids].sort((a, b) => a - b).join(",");
const dirty = !!data && canonical(disabled) !== canonical(data.disabledRaceIds);
const locked = blocked || busy || !data?.serverConfigSupported;
useEffect(() => {
let active = true;
setData(null);
setDisabled([]);
setError(null);
setSaved(false);
void api.raceAvailability(serverId).then(result => {
if (active) { setData(result); setDisabled(result.disabledRaceIds); }
}).catch(reason => { if (active) setError(asUiError(reason)); });
return () => { active = false; };
}, [serverId]);
async function save(): Promise<boolean> {
if (!data || locked) return false;
setBusy(true);
setError(null);
try {
const result = await api.raceAvailabilitySave(serverId, data.catalogSha256, data.realm, disabled);
setData(result);
setDisabled(result.disabledRaceIds);
setSaved(true);
onSaved();
return true;
} catch (reason) {
setError(asUiError(reason));
return false;
} finally {
setBusy(false);
}
}
useEffect(() => {
onRegisterGuard(dirty ? {
isDirty: () => dirty,
save,
discard: () => { setDisabled(data?.disabledRaceIds ?? []); setSaved(false); },
} : null);
return () => onRegisterGuard(null);
}, [dirty, disabled, data, busy, blocked, serverId, onRegisterGuard]);
return (
<section className="mt-3 space-y-3 border-t border-line/40 pt-3" aria-label={t("races.available")}>
<h3 className="text-sm font-semibold">{t("races.available")}</h3>
{error && <p role="alert" className="text-xs text-bad">{error.human.code === "unknown" ? error.technical : human(error.human).message}</p>}
{data && <>
<p className="text-xs text-muted">{t("races.count", { allowed: data.races.length - disabled.length, disabled: disabled.length })}</p>
{!data.serverConfigSupported && <p className="text-xs text-warn">{t("races.updateServer")}</p>}
{data.catalogNeedsReview && <p className="text-xs text-warn">{t("races.reviewCatalog")}</p>}
{data.unrecognizedRaceIds.length > 0 && <p className="text-xs text-warn">{t("races.unknownIds", { ids: data.unrecognizedRaceIds.join(", ") })}</p>}
<input value={query} onChange={event => setQuery(event.target.value)} placeholder={t("set.search")}
aria-label={t("set.search")} className="w-full rounded border border-line bg-bg px-2 py-1 text-sm" />
<div className="grid gap-3 sm:grid-cols-2">
{(["horde", "alliance", null] as const).map(faction => {
const races = data.races.filter(race => race.faction === faction);
if (faction === null && !races.length) return null;
const title = t(faction === "horde" ? "races.horde" : faction === "alliance" ? "races.alliance" : "races.available");
return (
<div key={faction ?? "other"} className={faction === null ? "sm:col-span-2" : undefined}>
<div className="max-h-52 overflow-auto rounded-md border border-line/60">
<table className="w-full text-left text-xs" aria-label={title}>
<thead className="sticky top-0 bg-bg text-muted">
<tr><th scope="col" className="w-12 px-3 py-2 font-medium">ID</th>
<th scope="col" className="px-3 py-2 font-medium text-ink">{title}</th>
<th scope="col" className="w-10 px-3 py-2"><span className="sr-only">{t("races.allowCreation")}</span></th></tr>
</thead>
<tbody className="divide-y divide-line/40">
{races.filter(race => `${race.name} ${race.id}`.toLowerCase().includes(query.trim().toLowerCase())).map(race => (
<tr key={race.id} className="hover:bg-line/20">
<td className="px-3 py-1.5 tabular-nums text-muted">{race.id}</td>
<td className="px-3 py-1.5">{race.name}</td>
<td className="px-3 py-1.5 text-right">
<input type="checkbox" checked={!disabled.includes(race.id)} disabled={locked}
aria-label={`${race.name} (${race.id}): ${t("races.allowCreation")}`}
onChange={event => { setSaved(false); setDisabled(previous => event.target.checked ? previous.filter(id => id !== race.id) : [...previous, race.id]); }} />
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
})}
</div>
<div className="flex flex-wrap gap-2">
<Button size="sm" variant="ghost" disabled={locked} onClick={() => { setDisabled([]); setSaved(false); }}>{t("races.allowAll")}</Button>
<Button size="sm" variant="primary" disabled={locked || (!dirty && !data.catalogNeedsReview)} onClick={() => void save()}>{t("set.save")}</Button>
<Button size="sm" variant="ghost" disabled={busy || !dirty} onClick={() => { setDisabled(data.disabledRaceIds); setSaved(false); }}>{t("set.discard")}</Button>
</div>
<p className="text-xs text-muted">{t(saved ? "races.saved" : "races.restart")}</p>
<p className="text-xs text-muted">{t("races.clientPending")}</p>
<p className="text-xs text-muted">{t("races.existing")}</p>
</>}
</section>
);
}
+184
View File
@@ -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("<II", data, offset)
if not 0 < race <= 255 or race in rows:
raise ValueError("Invalid or duplicate RaceID")
names = {}
for field in NAME_FIELDS:
name_offset = struct.unpack_from("<I", data, offset + field * 4)[0]
if name_offset >= 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("<I", result, row["offset"] + 4, row["flags"] | 1)
verified = read_dbc(result)
restored = bytearray(result)
for race in disabled:
row = rows[race]
if verified[race]["flags"] != row["flags"] | 1:
raise ValueError("Flags verification failed")
struct.pack_into("<I", restored, row["offset"] + 4, row["flags"])
if restored != original:
raise ValueError("Unexpected byte changes")
return bytes(result)
def fingerprint(original, disabled, profile):
inputs = {"sourceDbcSha256": hashlib.sha256(original).hexdigest(),
"disabledRaceIds": sorted(set(disabled)), "algorithmVersion": ALGORITHM_VERSION,
"profile": profile}
return hashlib.sha256(json.dumps(inputs, sort_keys=True, separators=(",", ":")).encode()).hexdigest()
class Storm:
def __init__(self, library):
self.api = ctypes.WinDLL(str(library), use_last_error=True)
handle = ctypes.c_void_p
word = ctypes.c_uint32
signatures = {
"SFileOpenArchive": [ctypes.c_wchar_p, word, word, ctypes.POINTER(handle)],
"SFileCloseArchive": [handle],
"SFileExtractFile": [handle, ctypes.c_char_p, ctypes.c_wchar_p, word],
"SFileCreateArchive": [ctypes.c_wchar_p, word, word, ctypes.POINTER(handle)],
"SFileAddFileEx": [handle, ctypes.c_wchar_p, ctypes.c_char_p, word, word, word],
}
for name, arguments in signatures.items():
function = getattr(self.api, name)
function.argtypes = arguments
function.restype = ctypes.c_int
def check(self, name, *args):
if not getattr(self.api, name)(*args):
raise OSError(ctypes.get_last_error(), name)
def extract(self, archive, member, destination):
handle = ctypes.c_void_p()
self.check("SFileOpenArchive", str(archive), 0, 0x100, ctypes.byref(handle))
try:
self.check("SFileExtractFile", handle, member.encode("ascii"),
str(destination), 0)
finally:
self.check("SFileCloseArchive", handle)
def create(self, archive, source):
handle = ctypes.c_void_p()
self.check("SFileCreateArchive", str(archive), 0, 4, ctypes.byref(handle))
try:
self.check("SFileAddFileEx", handle, str(source), MEMBER.encode("ascii"),
0x01000000, 0, 0)
finally:
self.check("SFileCloseArchive", handle)
def generate_experiment(storm, source_mpq, output, disabled, profile="unverified"):
source_mpq = Path(source_mpq).resolve(strict=True)
output = Path(output).absolute()
if output.exists():
raise FileExistsError(output)
output.parent.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory(prefix=".race-experiment-", dir=output.parent) as temp:
staging = Path(temp) / "result"
staging.mkdir()
report = build_experiment(storm, source_mpq, staging, disabled, profile)
staging.rename(output)
return report
def build_experiment(storm, source_mpq, output, disabled, profile):
with source_mpq.open("rb") as source:
source_sha = hashlib.file_digest(source, "sha256").hexdigest()
original_path = output / "original.dbc"
storm.extract(source_mpq, MEMBER, original_path)
original = original_path.read_bytes()
rows = read_dbc(original)
generated = patch_dbc(original, disabled)
generated_path = output / "generated.dbc"
generated_path.write_bytes(generated)
overlay = output / "experiment.MPQ"
storm.create(overlay, generated_path)
with tempfile.TemporaryDirectory() as temp:
extracted = Path(temp) / "roundtrip.dbc"
storm.extract(overlay, MEMBER, extracted)
if extracted.read_bytes() != generated:
raise ValueError("MPQ extraction does not match generated DBC")
report = {
"sourceMpq": str(source_mpq), "sourceMpqSha256": source_sha,
"algorithmVersion": ALGORITHM_VERSION, "profile": profile,
"fingerprint": fingerprint(original, disabled, profile),
"sourceDbcSha256": hashlib.sha256(original).hexdigest(),
"generatedDbcSha256": hashlib.sha256(generated).hexdigest(),
"disabled": [{"id": race, "name": rows[race]["name"],
"oldFlags": rows[race]["flags"], "newFlags": rows[race]["flags"] | 1}
for race in sorted(set(disabled))],
"catalog": [{"id": race, "name": row["name"], "flags": row["flags"]}
for race, row in sorted(rows.items())],
"dbcValidation": "PASS", "onlyRequestedBitChanged": "PASS", "mpqRoundtrip": "PASS",
"effectiveSourceInRunningClient": "UNVERIFIED", "overlayPriority": "UNVERIFIED",
"creationMenu": "NOT RUN", "otherRaceCreation": "NOT RUN",
"existingCharacterLogin": "NOT RUN", "rollbackInGame": "NOT RUN",
"verdict": "PARTIAL",
}
with source_mpq.open("rb") as source:
if hashlib.file_digest(source, "sha256").hexdigest() != source_sha:
raise ValueError("Source archive changed during the experiment")
(output / "report.json").write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
return report
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--storm", type=Path, required=True)
parser.add_argument("--source-mpq", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
parser.add_argument("--disable", type=int, nargs="*", required=True)
parser.add_argument("--profile", default="unverified", help="Recorded input; not a support declaration")
args = parser.parse_args()
report = generate_experiment(Storm(args.storm), args.source_mpq, args.output, args.disable, args.profile)
print(json.dumps({key: value for key, value in report.items() if key != "catalog"}, indent=2))
if __name__ == "__main__":
main()
+59
View File
@@ -0,0 +1,59 @@
"""Version-pinned offline experiment; never installs files or launches the game."""
import hashlib
SOURCE_SHA256 = "8816ef9b6839652db60da6de7d2a6d09ceed1f80323ca5c8e55271a4d5aacac3"
PROBE_IDS = {82, 83, 97}
def build_probe(source, disabled):
if hashlib.sha256(source).hexdigest() != SOURCE_SHA256:
raise ValueError("Unsupported CharacterCreate.lua fingerprint")
disabled = set(disabled)
if not disabled <= PROBE_IDS:
raise ValueError("This experiment supports only previously verified probe RaceIDs")
text = source.decode("utf-8").replace("\r", "")
prefix = "local CoaManagedDisabled = {" + ",".join(f"[{race}]=true" for race in sorted(disabled)) + "}\n"
prefix += """local function CoaManagedRaceDisabled(raceId)
return raceId ~= nil and CoaManagedDisabled[raceId] == true
end
local function CoaManagedDisabledLabel()
local labels = {
enUS = "Disabled by server", enGB = "Disabled by server",
ruRU = "Отключено сервером", deDE = "Vom Server deaktiviert",
frFR = "Désactivé par le serveur", esES = "Desactivada por el servidor",
esMX = "Desactivada por el servidor", zhCN = "服务器已禁用"
}
return labels[GetLocale and GetLocale() or "enUS"] or labels.enUS
end
"""
replacements = [
('local hide = hidden["#"..i] or hidden[name] or hidden[strlower(name or "")]',
'local hide = CoaManagedRaceDisabled(button.bonusRaceID) or hidden["#"..i] or hidden[name] or hidden[strlower(name or "")]'),
('\t\t\t\tbutton.coaUserHidden = nil\n\t\t\t\tbutton:Show()\n',
'\t\t\t\tbutton.coaUserHidden = CoaManagedRaceDisabled(button.bonusRaceID) or nil\n'
'\t\t\t\tif not button.coaUserHidden then button:Show() else button:Hide() end\n'),
("tinsert(rows, { id = i, raceID = raceID, name = name, faction = (ok and faction) or 'Alliance' })",
"tinsert(rows, { id = i, raceID = raceID, policyRaceID = button.bonusRaceID, name = name, faction = (ok and faction) or 'Alliance' })"),
('if not f.draft or not self.raceName then',
'if self.coaServerDisabled or not f.draft or not self.raceName then'),
("\t\t\t\trow.label:SetText((info.faction == 'Horde' and '|cffff2020' or '|cff00a0ff')..info.name..' |cffaaa37a(ID '..info.raceID..')|r')\n"
"\t\t\t\tlocal hidden = f.draft and (f.draft['#'..info.id] or f.draft[info.name] or f.draft[strlower(info.name)])\n"
"\t\t\t\trow:SetChecked(not hidden)",
""" row.coaServerDisabled = CoaManagedRaceDisabled(info.policyRaceID)
local hidden = f.draft and (f.draft['#'..info.id] or f.draft[info.name] or f.draft[strlower(info.name)])
if row.coaServerDisabled then
row.label:SetText('|cff808080'..info.name..' (ID '..info.raceID..') - '..CoaManagedDisabledLabel()..'|r')
row:Disable()
else
row.label:SetText((info.faction == 'Horde' and '|cffff2020' or '|cff00a0ff')..info.name..' |cffaaa37a(ID '..info.raceID..')|r')
row:Enable()
end
row:SetChecked(not row.coaServerDisabled and not hidden)"""),
]
for old, new in replacements:
if text.count(old) != 1:
raise ValueError("Expected exactly one known Lua integration point")
text = text.replace(old, new)
return (prefix + text).encode("utf-8")
+108
View File
@@ -0,0 +1,108 @@
"""Synthetic MPQ roundtrips using an explicitly selected local Unicode StormLib DLL."""
import hashlib
import os
from pathlib import Path
import struct
import tempfile
import unittest
from unittest.mock import patch
from race_availability_poc import Storm, generate_experiment, patch_dbc
from test_race_availability_poc import fixture
@unittest.skipUnless(os.environ.get("COA_TEST_STORMLIB"), "Set COA_TEST_STORMLIB to a Unicode StormLib DLL")
class MpqExperimentTests(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.addCleanup(self.temporary.cleanup)
self.root = Path(self.temporary.name)
self.storm = Storm(Path(os.environ["COA_TEST_STORMLIB"]))
self.original = fixture(ids=(1, *range(12, 62)))
source_dbc = self.root / "source.dbc"
source_dbc.write_bytes(self.original)
self.source_mpq = self.root / "source.MPQ"
self.storm.create(self.source_mpq, source_dbc)
self.source_bytes = self.source_mpq.read_bytes()
self.output = self.root / "output"
def test_one_and_fifty_races_still_one_archive_with_one_game_file(self):
for count in [1, 50]:
with self.subTest(count=count):
output = self.root / f"output-{count}"
disabled = list(range(12, 12 + count))
report = generate_experiment(self.storm, self.source_mpq, output, disabled)
archives = list(output.glob("*.MPQ"))
self.assertEqual(len(archives), 1)
header = struct.unpack_from("<4sIIHHIIII", archives[0].read_bytes())
self.assertEqual(header[0], b"MPQ\x1a")
self.assertEqual(header[-1], 2)
listfile = self.root / f"listfile-{count}.txt"
self.storm.extract(archives[0], "(listfile)", listfile)
self.assertEqual(listfile.read_text().splitlines(), [r"DBFilesClient\ChrRaces.dbc"])
self.assertEqual((output / "generated.dbc").read_bytes(), patch_dbc(self.original, disabled))
self.assertEqual(report["mpqRoundtrip"], "PASS")
self.assertEqual(report["creationMenu"], "NOT RUN")
self.assertEqual(self.source_mpq.read_bytes(), self.source_bytes)
def test_regeneration_has_identical_dbc_and_fingerprint(self):
first = generate_experiment(self.storm, self.source_mpq, self.output, [12, 15])
other = self.root / "other"
second = generate_experiment(self.storm, self.source_mpq, other, [15, 12, 12])
self.assertEqual(first["fingerprint"], second["fingerprint"])
self.assertEqual((self.output / "generated.dbc").read_bytes(), (other / "generated.dbc").read_bytes())
def test_existing_output_and_user_files_not_overwritten(self):
self.output.mkdir()
user_file = self.output / "experiment.MPQ"
user_file.write_bytes(b"user archive")
with self.assertRaises(FileExistsError):
generate_experiment(self.storm, self.source_mpq, self.output, [12])
self.assertEqual(user_file.read_bytes(), b"user archive")
self.assertEqual(self.source_mpq.read_bytes(), self.source_bytes)
def test_failed_packaging_does_not_publish_output(self):
with patch.object(self.storm, "create", side_effect=OSError("synthetic packaging failure")):
with self.assertRaises(OSError):
generate_experiment(self.storm, self.source_mpq, self.output, [12])
self.assertFalse(self.output.exists())
self.assertEqual(self.source_mpq.read_bytes(), self.source_bytes)
self.assertEqual(list(self.root.glob(".race-experiment-*")), [])
def test_unreadable_archive_does_not_publish_output(self):
broken = self.root / "broken.MPQ"
broken.write_bytes(b"not an archive")
with self.assertRaises(OSError):
generate_experiment(self.storm, broken, self.output, [12])
self.assertFalse(self.output.exists())
self.assertEqual(broken.read_bytes(), b"not an archive")
def test_invalid_race_does_not_publish_output(self):
with self.assertRaises(ValueError):
generate_experiment(self.storm, self.source_mpq, self.output, [255])
self.assertFalse(self.output.exists())
def test_empty_selection_preserves_original_bytes(self):
report = generate_experiment(self.storm, self.source_mpq, self.output, [])
self.assertEqual((self.output / "generated.dbc").read_bytes(), self.original)
self.assertEqual(report["sourceMpqSha256"], hashlib.sha256(self.source_bytes).hexdigest())
self.assertEqual(report["disabled"], [])
def test_failed_roundtrip_does_not_publish_output(self):
extract = self.storm.extract
def corrupt_roundtrip(archive, member, destination):
extract(archive, member, destination)
if archive.name == "experiment.MPQ":
destination.write_bytes(b"incorrect extracted bytes")
with patch.object(self.storm, "extract", side_effect=corrupt_roundtrip):
with self.assertRaisesRegex(ValueError, "MPQ extraction"):
generate_experiment(self.storm, self.source_mpq, self.output, [12])
self.assertFalse(self.output.exists())
self.assertEqual(self.source_mpq.read_bytes(), self.source_bytes)
if __name__ == "__main__":
unittest.main()
+113
View File
@@ -0,0 +1,113 @@
import struct
import unittest
from race_availability_poc import NAME_FIELDS, STOCK, fingerprint, patch_dbc, read_dbc
def fixture(ids=(1, 82, 83, 97), flags=0x8C):
rows = bytearray()
for race in ids:
row = bytearray(276)
struct.pack_into("<II", row, 0, race, flags)
struct.pack_into("<I", row, 14 * 4, 1)
rows.extend(row)
strings = b"\0Synthetic race\0"
return struct.pack("<4sIIII", b"WDBC", len(ids), 69, 276, len(strings)) + rows + strings
class DbcExperimentTests(unittest.TestCase):
def test_only_requested_bit_changes(self):
original = fixture()
generated = patch_dbc(original, [82, 97])
self.assertEqual([i for i, pair in enumerate(zip(original, generated)) if pair[0] != pair[1]],
[20 + 276 + 4, 20 + 3 * 276 + 4])
self.assertEqual(read_dbc(generated)[82]["flags"], 0x8D)
def test_empty_set_preserves_original(self):
self.assertEqual(patch_dbc(fixture(), []), fixture())
def test_duplicates_and_order_normalized(self):
self.assertEqual(patch_dbc(fixture(), [97, 82, 82]), patch_dbc(fixture(), [82, 97]))
def test_already_disabled_idempotent(self):
original = fixture(flags=0x8D)
self.assertEqual(patch_dbc(original, [82]), original)
def test_each_generation_uses_original(self):
original = fixture()
patch_dbc(original, [82])
self.assertEqual(read_dbc(patch_dbc(original, [97]))[82]["flags"], 0x8C)
def test_unknown_or_stock_rejected(self):
for race in [0, 42, 256, -1, *sorted(STOCK)]:
with self.subTest(race=race), self.assertRaises(ValueError):
patch_dbc(fixture(), [race])
def test_bad_headers_rejected(self):
for data in [b"", fixture()[:19], b"WDB2" + fixture()[4:], fixture(ids=()),
fixture()[:-1], fixture() + b"x", fixture(ids=(82, 82))]:
with self.subTest(data=data[:20]), self.assertRaises(ValueError):
read_dbc(data)
def test_unrecognized_layout_rejected(self):
for offset, value in [(8, 68), (12, 272), (16, 0), (4, 0xFFFFFFFF)]:
data = bytearray(fixture())
struct.pack_into("<I", data, offset, value)
with self.subTest(offset=offset), self.assertRaises(ValueError):
read_dbc(data)
def test_bad_string_offset_rejected(self):
for field in NAME_FIELDS:
data = bytearray(fixture())
struct.pack_into("<I", data, 20 + field * 4, 99999)
with self.subTest(field=field), self.assertRaises(ValueError):
read_dbc(data)
def test_nonempty_first_string_supported(self):
data = bytearray(fixture())
data[20 + 4 * 276] = ord("x")
self.assertEqual(len(read_dbc(data)), 4)
def test_reenable_restores_original_flags_including_original_bit(self):
for flags in [0, 1, 12, 13, 0xFFFFFFFF]:
original = fixture(flags=flags)
patch_dbc(original, [82])
self.assertEqual(patch_dbc(original, []), original)
def test_one_five_and_fifty_races(self):
original = fixture(ids=(1, *range(12, 62)))
for count in [1, 5, 50]:
disabled = set(range(12, 12 + count))
generated = patch_dbc(original, disabled)
original_rows, generated_rows = read_dbc(original), read_dbc(generated)
self.assertEqual({race for race in original_rows
if original_rows[race]["flags"] != generated_rows[race]["flags"]}, disabled)
def test_invalid_id_in_table(self):
for race in [0, 256, 0xFFFFFFFF]:
with self.subTest(race=race), self.assertRaises(ValueError):
read_dbc(fixture(ids=(race,)))
def test_string_block_must_terminate(self):
with self.assertRaises(ValueError):
read_dbc(fixture()[:-1] + b"x")
def test_invalid_utf8_in_localized_name(self):
data = bytearray(fixture())
data[20 + 4 * 276 + 1] = 0xFF
with self.assertRaises(ValueError):
read_dbc(data)
def test_fingerprint_normalizes_order_and_duplicates(self):
self.assertEqual(fingerprint(fixture(), [97, 82, 82], "test"),
fingerprint(fixture(), [82, 97], "test"))
def test_fingerprint_changes_with_every_input(self):
baseline = fingerprint(fixture(), [82], "test")
self.assertNotEqual(baseline, fingerprint(fixture(flags=0), [82], "test"))
self.assertNotEqual(baseline, fingerprint(fixture(), [97], "test"))
self.assertNotEqual(baseline, fingerprint(fixture(), [82], "other-profile"))
if __name__ == "__main__":
unittest.main()
+157
View File
@@ -0,0 +1,157 @@
import importlib.util
import os
from pathlib import Path
import unittest
from race_visibility_lua_poc import build_probe
@unittest.skipUnless(os.environ.get("COA_TEST_CHARACTER_CREATE_LUA") and importlib.util.find_spec("lupa"),
"Requires an explicitly selected extracted Lua and installed Lua 5.1 runtime")
class ManagedFilterProbeTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
from lupa.lua51 import LuaRuntime
cls.runtime_type = LuaRuntime
cls.source = Path(os.environ["COA_TEST_CHARACTER_CREATE_LUA"]).read_bytes()
cls.generated = build_probe(cls.source, [82, 83, 97]).decode()
cls.prefix = cls.generated[:cls.generated.index("CHAR_CREATE_HELP_NEXT_BUTTON")]
def rows(self, race=82, personal=False, locale="enUS"):
lua = self.runtime_type(unpack_returned_tuples=True)
lua.execute("""
F = {offset=0, entries={}, draft={}, rows={}}
strlower = string.lower
function makeRow()
local row={label={}}
row.label.SetText=function(self,value) self.text=value end
row.SetChecked=function(self,value) self.checked=value end
row.Disable=function(self) self.enabled=false end
row.Enable=function(self) self.enabled=true end
row.Show=function(self) self.visible=true end
row.Hide=function(self) self.visible=false end
return row
end
F.rows[1]=makeRow()
""")
lua.globals().GetLocale = lambda: locale
entry = lua.table_from({"id": 2, "raceID": race, "policyRaceID": race, "name": "Synthetic", "faction": "Alliance"})
lua.globals().F.entries[1] = entry
if personal:
lua.globals().F.draft.Synthetic = True
start = self.generated.index("function CharacterCreate_RefreshRaceFilterRows()")
end = self.generated.index("function CharacterCreate_ToggleRaceFilter()", start)
lua.execute(self.prefix + "\nlocal filter=F; local VISIBLE=1; local UpdateScrollBar=function() end\n"
+ self.generated[start:end])
lua.globals().CharacterCreate_RefreshRaceFilterRows()
return lua
def test_entire_generated_source_compiles_in_lua51(self):
self.runtime_type().execute("assert(loadstring(...))", self.generated)
def test_disabled_row_unchecked_disabled_and_labelled(self):
for race in [82, 83, 97]:
with self.subTest(race=race):
lua = self.rows(race)
row = lua.globals().F.rows[1]
self.assertFalse(row.checked)
self.assertFalse(row.enabled)
self.assertIn("Disabled by server", row.label.text)
self.assertTrue(row.label.text.startswith("|cff808080"))
def test_allowed_row_retains_personal_preference(self):
for personal in [False, True]:
with self.subTest(personal=personal):
row = self.rows(13, personal).globals().F.rows[1]
self.assertTrue(row.enabled)
self.assertEqual(bool(row.checked), not personal)
def test_recycled_scroll_row_is_reenabled(self):
lua = self.rows()
lua.globals().F.entries[1].policyRaceID = 13
lua.globals().F.entries[1].raceID = 13
lua.globals().CharacterCreate_RefreshRaceFilterRows()
self.assertTrue(lua.globals().F.rows[1].enabled)
self.assertTrue(lua.globals().F.rows[1].checked)
def test_unknown_identity_never_treated_as_button_index(self):
lua = self.rows()
lua.globals().F.entries[1].policyRaceID = None
lua.globals().F.entries[1].id = 82
lua.globals().CharacterCreate_RefreshRaceFilterRows()
self.assertTrue(lua.globals().F.rows[1].enabled)
def test_check_all_cannot_remove_managed_restriction(self):
lua = self.rows(82, True)
start = self.generated.index("local function SetAllChecked(checked)")
end = self.generated.index("local save = MakeGlueButton", start)
check_all = lua.execute(self.prefix + "\nlocal f=F;\n" + self.generated[start:end] + "\nreturn SetAllChecked")
check_all(True)
self.assertFalse(lua.globals().F.rows[1].checked)
self.assertFalse(lua.globals().F.rows[1].enabled)
self.assertIsNone(lua.globals().F.draft.Synthetic)
def test_disabled_click_callback_does_not_mutate_draft(self):
lua = self.rows(82, True)
start = self.generated.index("row:SetScript('OnClick', function(self)") + len("row:SetScript('OnClick', function(self)")
end = self.generated.index("\n\t\t\tend)", start)
callback = lua.execute("local f=F; return function(self)" + self.generated[start:end] + "\nend")
row = lua.globals().F.rows[1]
row.raceName = "Synthetic"
callback(row)
self.assertTrue(lua.globals().F.draft.Synthetic)
def test_six_languages(self):
for locale, label in [("enUS", "Disabled by server"), ("ruRU", "Отключено сервером"),
("deDE", "Vom Server deaktiviert"), ("frFR", "Désactivé par le serveur"),
("esES", "Desactivada por el servidor"), ("zhCN", "服务器已禁用")]:
with self.subTest(locale=locale):
self.assertIn(label, self.rows(locale=locale).globals().F.rows[1].label.text)
def test_layout_fallback_never_reveals_managed_race(self):
lua = self.runtime_type()
lua.execute("""
strlower=string.lower
CharacterCreate_GetHiddenRaceSet=function() return {['#1']=true,['#2']=true} end
GetFactionForRace=function() return '', 'Alliance' end
function makeButton(i,race)
local b={bonusRaceID=race,Text={GetText=function() return 'Synthetic' end}}
b.Hide=function(self) self.visible=false end
b.Show=function(self) self.visible=true end
b.SetScale=function() end; b.ClearAllPoints=function() end
b.SetPoint=function() end; b.SetFaction=function() end
_G['CharCreateRaceButton'..i]=b
end
makeButton(1,1); makeButton(2,82)
""")
start = self.generated.index("function CharacterCreate_LayoutRaceButtons(")
end = self.generated.index("MAX_CLASSES_PER_RACE =", start)
lua.execute(self.prefix + self.generated[start:end])
lua.globals().CharacterCreate_LayoutRaceButtons(2)
self.assertTrue(lua.globals().CharCreateRaceButton1.visible)
self.assertFalse(lua.globals().CharCreateRaceButton2.visible)
def test_lifting_restriction_keeps_previous_personal_choice(self):
for personal in [False, True]:
with self.subTest(personal=personal):
lua = self.rows(82, personal)
generated = build_probe(self.source, []).decode()
prefix = generated[:generated.index("CHAR_CREATE_HELP_NEXT_BUTTON")]
start = generated.index("function CharacterCreate_RefreshRaceFilterRows()")
end = generated.index("function CharacterCreate_ToggleRaceFilter()", start)
lua.execute(prefix + "\nlocal filter=F; local VISIBLE=1; local UpdateScrollBar=function() end\n"
+ generated[start:end])
lua.globals().CharacterCreate_RefreshRaceFilterRows()
row = lua.globals().F.rows[1]
self.assertTrue(row.enabled)
self.assertEqual(bool(row.checked), not personal)
def test_unsupported_source_and_ids_fail(self):
with self.assertRaises(ValueError):
build_probe(b"unknown client source", [82])
with self.assertRaises(ValueError):
build_probe(self.source, [1])
if __name__ == "__main__":
unittest.main()