mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 03:51:01 +00:00
feat(manager): configure per-race character creation
This commit is contained in:
1 parent
925a81f4f2
commit
8ad06fd78a
24 files changed
+2058
-4
No files matched your search
@@ -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.
|
||||||
@@ -111,6 +111,13 @@ pub fn playerbots_conf_paths(root: &Path) -> Vec<PathBuf> {
|
|||||||
/// Checks whether playerbots are configured to allow custom races.
|
/// Checks whether playerbots are configured to allow custom races.
|
||||||
/// Returns true if AiPlayerbot.ExcludedBotRaces exists and does not exclude general 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> {
|
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) {
|
for path in playerbots_conf_paths(root) {
|
||||||
if let Ok(bytes) = fs::read(&path) {
|
if let Ok(bytes) = fs::read(&path) {
|
||||||
if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) {
|
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) {
|
for path in playerbots_conf_paths(root) {
|
||||||
if let Ok(bytes) = fs::read(&path) {
|
if let Ok(bytes) = fs::read(&path) {
|
||||||
if let Ok(mut conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) {
|
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(
|
conf.set(
|
||||||
"AiPlayerbot.ExcludedBotRaces",
|
"AiPlayerbot.ExcludedBotRaces",
|
||||||
&format!("\"{excluded}\""),
|
&format!("\"{excluded}\""),
|
||||||
@@ -1621,7 +1637,7 @@ mod tests {
|
|||||||
let temp = tempfile::tempdir().unwrap();
|
let temp = tempfile::tempdir().unwrap();
|
||||||
let conf = temp.path().join("Core/configs/modules/playerbots.conf");
|
let conf = temp.path().join("Core/configs/modules/playerbots.conf");
|
||||||
fs::create_dir_all(conf.parent().unwrap()).unwrap();
|
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();
|
sync_bot_races_config(temp.path(), false).unwrap();
|
||||||
assert_eq!(is_bot_custom_races_allowed(temp.path()), Some(false));
|
assert_eq!(is_bot_custom_races_allowed(temp.path()), Some(false));
|
||||||
let parsed =
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -48,6 +48,7 @@ pub mod population;
|
|||||||
pub mod portable;
|
pub mod portable;
|
||||||
pub mod process;
|
pub mod process;
|
||||||
pub mod ra;
|
pub mod ra;
|
||||||
|
pub mod race_availability;
|
||||||
pub mod realm_registry;
|
pub mod realm_registry;
|
||||||
pub mod realmlist;
|
pub mod realmlist;
|
||||||
pub mod realms;
|
pub mod realms;
|
||||||
|
|||||||
@@ -516,6 +516,20 @@ fn save_settings_in(
|
|||||||
let mut changed = Vec::new();
|
let mut changed = Vec::new();
|
||||||
for (key, value) in changes {
|
for (key, value) in changes {
|
||||||
let value = value.trim();
|
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) {
|
if let Some(field) = fields.get(key) {
|
||||||
crate::squid::validate(field, value)?;
|
crate::squid::validate(field, value)?;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, ¤t.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 {
|
||||||
|
¤t.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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -1237,6 +1237,32 @@ async fn custom_races_status(
|
|||||||
.await
|
.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]
|
#[tauri::command]
|
||||||
async fn custom_races_install(
|
async fn custom_races_install(
|
||||||
app: AppHandle,
|
app: AppHandle,
|
||||||
@@ -2847,6 +2873,8 @@ pub fn run() {
|
|||||||
console_risk,
|
console_risk,
|
||||||
console_command,
|
console_command,
|
||||||
custom_races_status,
|
custom_races_status,
|
||||||
|
race_availability,
|
||||||
|
race_availability_save,
|
||||||
custom_races_install,
|
custom_races_install,
|
||||||
custom_races_uninstall,
|
custom_races_uninstall,
|
||||||
custom_races_toggle_client,
|
custom_races_toggle_client,
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import type en from "./en";
|
|||||||
|
|
||||||
/** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */
|
/** Deutsch. Entwurf einer Übersetzung; vor der Veröffentlichung von Muttersprachlern zu prüfen. */
|
||||||
const de: Partial<Record<keyof typeof en, string>> = {
|
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.",
|
"mod.switching": "Änderungen werden angewendet… Bitte warten.",
|
||||||
"portable.experimental": "Experimentelle Funktion: Portable Charaktere befinden sich noch in aktiver Entwicklung. Fehler können auftreten.",
|
"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.",
|
"remote.clientReady": "Dein Client ist bereit. Speichere die Hostadresse und klicke auf Spielen.",
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
/** English is the source of truth: every key must exist here; other locales fall back to it. */
|
/** English is the source of truth: every key must exist here; other locales fall back to it. */
|
||||||
const en = {
|
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.",
|
"mod.switching": "Applying changes… Please wait.",
|
||||||
"remote.clientReady": "Your client is ready. Save the host’s address, then press Play.",
|
"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.",
|
"remote.clientIntro": "Choose an existing game client or download one here. No local server is needed.",
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import type en from "./en";
|
|||||||
|
|
||||||
/** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */
|
/** Español. Traducción provisional; debe revisarla un hablante nativo antes de publicarse. */
|
||||||
const es: Partial<Record<keyof typeof en, string>> = {
|
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.",
|
"mod.switching": "Aplicando cambios… Espera un momento.",
|
||||||
"portable.experimental": "Función experimental: los personajes portátiles siguen en desarrollo activo. Pueden producirse errores.",
|
"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.",
|
"remote.clientReady": "Tu cliente está listo. Guarda la dirección del anfitrión y pulsa Jugar.",
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import type en from "./en";
|
|||||||
|
|
||||||
/** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */
|
/** Français. Traduction provisoire ; à faire relire par un locuteur natif avant la publication. */
|
||||||
const fr: Partial<Record<keyof typeof en, string>> = {
|
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.",
|
"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.",
|
"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.",
|
"remote.clientReady": "Votre client est prêt. Enregistrez l’adresse de l’hôte, puis cliquez sur Jouer.",
|
||||||
|
|||||||
@@ -2,6 +2,19 @@ import type en from "./en";
|
|||||||
|
|
||||||
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
|
/** Русский. Черновой перевод; требует проверки носителем перед релизом. */
|
||||||
const ru: Partial<Record<keyof typeof en, string>> = {
|
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": "Применяем изменения… Подождите.",
|
"mod.switching": "Применяем изменения… Подождите.",
|
||||||
"portable.experimental": "Экспериментальная функция: портативные персонажи ещё в активной разработке. Возможны ошибки.",
|
"portable.experimental": "Экспериментальная функция: портативные персонажи ещё в активной разработке. Возможны ошибки.",
|
||||||
"remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».",
|
"remote.clientReady": "Клиент готов. Сохраните адрес хоста и нажмите «Играть».",
|
||||||
|
|||||||
@@ -1,5 +1,18 @@
|
|||||||
/** Simplified Chinese localization (zh-CN) */
|
/** Simplified Chinese localization (zh-CN) */
|
||||||
const zh = {
|
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": "正在应用更改…请稍候。",
|
"mod.switching": "正在应用更改…请稍候。",
|
||||||
"portable.experimental": "实验性功能:便携角色仍在积极开发中,可能存在错误。",
|
"portable.experimental": "实验性功能:便携角色仍在积极开发中,可能存在错误。",
|
||||||
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
|
"remote.clientReady": "客户端已就绪。保存服主地址,然后点击开始游戏。",
|
||||||
|
|||||||
@@ -393,6 +393,16 @@ export interface ClientDownloadCheck {
|
|||||||
|
|
||||||
export type ClientPatchState = "not-installed" | "enabled" | "disabled" | "partially-installed";
|
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 {
|
export interface CustomRacesClientStatus {
|
||||||
version: string | null;
|
version: string | null;
|
||||||
state: ClientPatchState;
|
state: ClientPatchState;
|
||||||
@@ -553,6 +563,9 @@ export const api = {
|
|||||||
clientDownload: (id: string, parent: string) => invoke<ClientInfo>("client_download", { id, parent }),
|
clientDownload: (id: string, parent: string) => invoke<ClientInfo>("client_download", { id, parent }),
|
||||||
clientCancel: () => invoke<void>("client_cancel"),
|
clientCancel: () => invoke<void>("client_cancel"),
|
||||||
customRacesStatus: (id: string) => invoke<CustomRacesClientStatus | null>("custom_races_status", { id }),
|
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 }),
|
customRacesUninstall: (id: string) => invoke<void>("custom_races_uninstall", { id }),
|
||||||
customRacesInstall: (id: string) => invoke<void>("custom_races_install", { id }),
|
customRacesInstall: (id: string) => invoke<void>("custom_races_install", { id }),
|
||||||
customRacesToggleClient: (id: string, enabled: boolean) => invoke<void>("custom_races_toggle_client", { id, enabled }),
|
customRacesToggleClient: (id: string, enabled: boolean) => invoke<void>("custom_races_toggle_client", { id, enabled }),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { Card } from "@/components/ui/card";
|
|||||||
import { useHuman, useI18n, useSchemaText, type Key } from "@/i18n";
|
import { useHuman, useI18n, useSchemaText, type Key } from "@/i18n";
|
||||||
import { auctionWeightKey, canonicalModuleValue, moduleEnableKey, moduleField, retiredModuleKeys } from "@/lib/moduleSettings";
|
import { auctionWeightKey, canonicalModuleValue, moduleEnableKey, moduleField, retiredModuleKeys } from "@/lib/moduleSettings";
|
||||||
import { AuctionTypes } from "@/screens/AuctionTypes";
|
import { AuctionTypes } from "@/screens/AuctionTypes";
|
||||||
|
import { RaceAvailabilitySettings } from "@/screens/RaceAvailabilitySettings";
|
||||||
import type { UnsavedChangesGuard } from "@/lib/unsavedGuard";
|
import type { UnsavedChangesGuard } from "@/lib/unsavedGuard";
|
||||||
|
|
||||||
/** The GitHub mark (lucide dropped its brand icons). */
|
/** The GitHub mark (lucide dropped its brand icons). */
|
||||||
@@ -104,11 +105,17 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
|
|||||||
}
|
}
|
||||||
}, [serverId]);
|
}, [serverId]);
|
||||||
const [guard, setGuard] = useState<UnsavedChangesGuard | null>(null);
|
const [guard, setGuard] = useState<UnsavedChangesGuard | null>(null);
|
||||||
|
const [racesGuard, setRacesGuard] = useState<UnsavedChangesGuard | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
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);
|
return () => onRegisterGuard?.(null);
|
||||||
}, [guard, onRegisterGuard]);
|
}, [guard, racesGuard, onRegisterGuard]);
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -317,6 +324,9 @@ export function ModulesPage({ serverId, onChanged, onRegisterGuard }: { serverId
|
|||||||
<SettingsPanel key={selected.id} className="sm:col-span-2">
|
<SettingsPanel key={selected.id} className="sm:col-span-2">
|
||||||
<h2 className="font-semibold">{t("mod.settingsOf", { name: selected.name })}</h2>
|
<h2 className="font-semibold">{t("mod.settingsOf", { name: selected.name })}</h2>
|
||||||
<ModuleSettings serverId={serverId} module={selected} onSaved={() => setChanged(true)} onRegisterGuard={setGuard} />
|
<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>
|
</SettingsPanel>
|
||||||
)}
|
)}
|
||||||
</Fragment>
|
</Fragment>
|
||||||
@@ -409,7 +419,7 @@ function ModuleSettings({ serverId, module, onSaved, onRegisterGuard }: { server
|
|||||||
else next[key] = value;
|
else next[key] = value;
|
||||||
return next;
|
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 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 => {
|
const visible = available.filter(s => {
|
||||||
if (module.id === "ah-bot" && auctionWeightKey(s.key)) return false;
|
if (module.id === "ah-bot" && auctionWeightKey(s.key)) return false;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
@@ -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")
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
@@ -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()
|
||||||
Reference in new issue
Block a user