mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-11 20:01:01 +00:00
merge: bring preview/modules-v2 up to date with master (base 0.9.7)
This commit is contained in:
commit
7d062faa1a
327 files changed
+15358
-1098
No files matched your search
@@ -25,4 +25,6 @@ jobs:
|
||||
- run: npm ci
|
||||
- run: node tools/check-i18n.mjs
|
||||
- run: npm run build
|
||||
- run: node --experimental-strip-types --test tools/*.test.mjs
|
||||
- run: python -B -m unittest discover -s tools -p test_launcher_bridge.py
|
||||
- run: cargo test --workspace --locked
|
||||
@@ -70,7 +70,9 @@ jobs:
|
||||
node-version: 22
|
||||
- run: npm ci
|
||||
- name: Tests
|
||||
run: cargo test --workspace -- --test-threads=1
|
||||
run: cargo test --workspace --locked -- --test-threads=1
|
||||
- run: node --experimental-strip-types --test tools/*.test.mjs
|
||||
- run: python -B -m unittest discover -s tools -p test_launcher_bridge.py
|
||||
- name: Build and sign (publish only when requested)
|
||||
id: tauri
|
||||
uses: tauri-apps/tauri-action@v0
|
||||
|
||||
@@ -35,6 +35,16 @@ Every published Manager version, including a clearly marked Pre-release, belongs
|
||||
CHANGELOG.md and changelog/history.json. Its GitHub release notes use the same reviewed record.
|
||||
Promotion updates that entry without duplicating it; Manager and server histories remain separate.
|
||||
|
||||
Translated release notes are a required preparation step before freezing a new Manager source. Follow the
|
||||
runbook's "Mandatory translated release notes during preparation" checklist: translate every new reviewed
|
||||
Manager/server note into Russian, German, French, Spanish and Simplified Chinese in
|
||||
`src/data/latest-note-translations.json`, keyed by exact canonical English text. Preserve existing translations
|
||||
and released fragments. Refresh the bundled server-history snapshot from a pinned canonical commit and record
|
||||
its provenance. Run `node --test tools/release-history.test.mjs`, `node tools/check-i18n.mjs` and `npm run build`;
|
||||
verify each new record, all six viewer languages, English fallback and offline use. Save source/version and checks
|
||||
in the handoff. Translations ship with Manager: a server-only release must record the carrying Manager version or
|
||||
pending translation delivery, without forcing another Manager release or changing already signed installers.
|
||||
|
||||
Before preparing or publishing a Manager/server release, read the maintained
|
||||
[release runbook](https://github.com/Corfirean/coa-server-build/blob/main/docs/server-release.md)
|
||||
and [build agent instructions](https://github.com/Corfirean/coa-server-build/blob/main/AGENTS.md).
|
||||
@@ -51,3 +61,16 @@ Server promotion uses the persistent Windows PC and the shared workflow's verifi
|
||||
production databases; run applicable local acceptance in disposable fixtures. Record the actual passed,
|
||||
failed and unrun checks. Verify both GitHub Latest and the public signed stable pointer before reporting
|
||||
that users can update. Leave the durable handoff described in the runbook for the next agent.
|
||||
|
||||
## Public documentation languages
|
||||
|
||||
Maintain the public README and every player/server-owner guide in all six Manager languages:
|
||||
English, Russian, German, French, Spanish and Simplified Chinese. English stays at `README.md`
|
||||
and the player-facing `docs/*.md`; translated READMEs use `README.<locale>.md`, with guides in
|
||||
`docs/<locale>/` (`ru`, `de`, `fr`, `es`, `zh-CN`). Language switches open the same page.
|
||||
When changing a public guide, update its counterparts in the same change. Write naturally for
|
||||
players in each language rather than translating word for word. Keep steps, feature limits and
|
||||
experimental Portable Characters warnings equivalent. Use actual UI labels; retain English labels
|
||||
where the released UI falls back to English. English screenshots stay in `docs/screenshots/`; localized images use its `ru`, `de`, `fr`, `es` and `zh-CN` subfolders. Keep the interface and annotation language consistent with each guide.
|
||||
The public guide index is `docs/README.md`; internal protocol specifications and audits are not
|
||||
player guides. Keep canonical release histories in English and link to them instead of making copies.
|
||||
+137
@@ -8,6 +8,143 @@ Published releases and previews. Pre-release entries do not change the stable up
|
||||
|
||||
Dates follow GitHub publication (UTC); legacy records retain their archived dates. Newest releases first.
|
||||
|
||||
## 0.9.8 — 2026-11-01
|
||||
|
||||
- Dungeon Clear can be enabled when SQUID bots are active.
|
||||
- Diagnostic exports include launcher stages and lock owner records.
|
||||
- Choose normal logs, errors only or no server logs in maintenance settings.
|
||||
- Update checks temporarily suspend bot population and restore your configuration.
|
||||
- Update validation keeps MySQL running between compatible checks.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.8) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.9.7...v0.9.8)
|
||||
|
||||
## 0.9.7 — 2026-10-30
|
||||
|
||||
- Show available update notes even when release history is cached.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.7) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.9.5...v0.9.7)
|
||||
|
||||
## 0.9.5 — 2026-10-30
|
||||
|
||||
- Account creation verifies a lost console reply against the requested login.
|
||||
- Abandoned game relay tunnels close their local connection immediately.
|
||||
- Keep disabled graphics components disabled during client updates.
|
||||
- Force-stop an unresponsive world after a failed normal shutdown.
|
||||
- Crash history includes reports from every world and Windows text files.
|
||||
- Show the online bot count for Playerbots on the overview.
|
||||
- Disable public server browsing in Settings.
|
||||
- Keep deliberate stops and update shutdowns from triggering crash restart.
|
||||
- Server startup waits for a responsive console before reporting success.
|
||||
- Restore the existing Manager window when launching it again.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.5) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.9.4...v0.9.5)
|
||||
|
||||
## 0.9.4 — 2026-10-29
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Settings group updates, startup and maintenance with translated release history.
|
||||
- Update and launch without updating share a divided button.
|
||||
- Maximizing the window keeps text and controls at a comfortable size.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.4) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.9.3...v0.9.4)
|
||||
|
||||
## 0.9.3 — 2026-10-29
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Slow server startup no longer triggers an early update rollback.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.3) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.9.2...v0.9.3)
|
||||
|
||||
## 0.9.2 — 2026-10-28
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- New server installation waits for slower database startup.
|
||||
- Find and edit CoA options in the existing server settings.
|
||||
- Simultaneous update checks share results or wait their turn.
|
||||
- Recovery can stop an unfinished world startup after restoring an older launcher.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.2) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.9.1...v0.9.2)
|
||||
|
||||
## 0.9.1 — 2026-10-28
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Existing unsupported character records no longer block unrelated server updates.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.1) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.9.0...v0.9.1)
|
||||
|
||||
## 0.9.0 — 2026-10-28
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Choose Parallel or Global level scaling in module settings.
|
||||
- Report Content Scaling problems directly to its repository.
|
||||
- Send custom race problem reports directly to their maintainer.
|
||||
- Internet sharing restores the LAN address after server restarts.
|
||||
- Configure custom race availability for new characters.
|
||||
- Public listing offers every realm that starts together, not just the main one.
|
||||
- Public listing is a short list of realms with a window for the details.
|
||||
- Configure profession and defense skill gains in server settings.
|
||||
- Starting a server sets the character classes of the realm chosen on Overview.
|
||||
- Start Wildcard and Warcraft Reborn together with Conquest of Azeroth.
|
||||
- Control portable characters and keep Manager running in the system tray.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.0) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.8.2...v0.9.0)
|
||||
|
||||
## 0.8.2 — 2026-10-27
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Installs the verified CoA Custom 1.9.2 client patches and quest safety addon, while recognizing existing 1.9.1 installations.
|
||||
- Server updates preserve the order of database migrations introduced during merges, preventing older snapshots from overwriting newer progression.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.8.2) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.8.1...v0.8.2)
|
||||
|
||||
## 0.8.1 — 2026-10-25
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Install the matching CoA Custom 1.9.1 client files.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.8.1) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.8.0...v0.8.1)
|
||||
|
||||
## 0.8.0 — 2026-10-24
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Server owners can allow portable characters per realm.
|
||||
- Allowing portable characters also prepares the server for portable sessions.
|
||||
- Managers stay compatible with the public connection service.
|
||||
- Relay connections recover and use the ports of the installed server.
|
||||
- Portable progress resumes after reconnecting or restarting the Manager.
|
||||
- Older copies on another host may require maintenance before returning.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.8.0) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.7.3...v0.8.0)
|
||||
|
||||
## 0.7.3 — 2026-10-23
|
||||
|
||||
**Portable Characters are experimental and may contain bugs.**
|
||||
|
||||
- Portable characters open again after using an experimental Manager.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.7.3) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.7.2...v0.7.3)
|
||||
|
||||
## 0.7.2 — 2026-10-23
|
||||
|
||||
- Advanced settings cover creature melee damage, spell damage and health by rank.
|
||||
- Internet sharing accepts existing port forwards to the same server.
|
||||
- The Auction House Bot card has a single switch.
|
||||
- More character and server browser controls now use your selected language.
|
||||
- Online player counts correctly separate random bots from players.
|
||||
- Custom races work with shared addon folders on another drive.
|
||||
- Configure the day and night cycle from the module settings.
|
||||
- A stalled database inspection no longer blocks server maintenance indefinitely.
|
||||
|
||||
[Release](https://github.com/Corfirean/coa-server-manager/releases/tag/v0.7.2) · [Source changes](https://github.com/Corfirean/coa-server-manager/compare/v0.7.1...v0.7.2)
|
||||
|
||||
## 0.7.1 — 2026-10-22
|
||||
|
||||
Close the game, then open Modules → Custom Races → Repair Client Patch to update existing patches, or Install Client Patch for a new installation. Updating the Manager does not automatically replace game files or update your server.
|
||||
|
||||
Generated
+20
-3
@@ -586,7 +586,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-core"
|
||||
version = "0.7.1"
|
||||
version = "0.9.7"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
@@ -634,7 +634,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "coa-release"
|
||||
version = "0.7.1"
|
||||
version = "0.9.7"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"coa-core",
|
||||
@@ -772,7 +772,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "csm-modules-preview"
|
||||
version = "0.7.1"
|
||||
version = "0.9.7"
|
||||
dependencies = [
|
||||
"coa-control-proto",
|
||||
"coa-core",
|
||||
@@ -784,6 +784,7 @@ dependencies = [
|
||||
"tauri-plugin-dialog",
|
||||
"tauri-plugin-opener",
|
||||
"tauri-plugin-process",
|
||||
"tauri-plugin-single-instance",
|
||||
"tauri-plugin-updater",
|
||||
"tracing",
|
||||
]
|
||||
@@ -4606,6 +4607,22 @@ dependencies = [
|
||||
"tauri-plugin",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-single-instance"
|
||||
version = "2.5.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "726b6da0dd5bd1344f84f4108a622c07d01923baacb5c803a605189672c5352f"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tauri",
|
||||
"thiserror 2.0.21",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"windows-sys 0.61.2",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tauri-plugin-updater"
|
||||
version = "2.13.1"
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/coa-core", "crates/coa-release", "crates/coa-registry-proto",
|
||||
exclude = ["services/coa-registry", "services/coa-registry-e2e", "services/coa-coordinator", "services/coa-relay"]
|
||||
|
||||
[workspace.package]
|
||||
version = "0.7.1"
|
||||
version = "0.9.7"
|
||||
edition = "2021"
|
||||
license = "AGPL-3.0-only"
|
||||
|
||||
|
||||
@@ -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,84 @@
|
||||
# CoA Server Manager
|
||||
|
||||
[English](README.md) · [Русский](README.ru.md) · **Deutsch** · [Français](README.fr.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md)
|
||||
|
||||
**Portable Characters sind experimentell.** Die Funktion wird noch entwickelt und kann Fehler enthalten.
|
||||
|
||||
Eine Windows-Anwendung, mit der du deinen eigenen **Conquest of Azeroth**-Server auf Basis von AzerothCore mit wenigen Schritten einrichtest und betreibst. Konsole, manuelle Konfigurationsdateien und separate Datenbankprogramme sind dafür nicht nötig. Entwickelt mit Tauri 2, Rust und React.
|
||||
|
||||

|
||||
|
||||
## Funktionen
|
||||
|
||||
- **Portable Characters — experimentell.** Bewahre Charaktere samt gespeicherten Versionen im Manager auf und übernimm vorhandene Charaktere von Realms, auf die du Zugriff hast. [Zur Anleitung](docs/de/PORTABLE-CHARACTERS.md).
|
||||
- **Realms entdecken.** Vergleiche öffentlich gelistete Realms nach Spielmodus, Höchststufe, Raten, Modulen und Spielerzahl.
|
||||
- **Einfache Einrichtung.** Der Manager lädt ein signiertes Serverpaket herunter, prüft die Dateien, richtet die Datenbank ein und erstellt dein erstes Konto. Downloads lassen sich fortsetzen; vorhandene Server kannst du ebenfalls einbinden.
|
||||
- **Starten, stoppen, spielen.** Steuere Datenbank, Auth- und Weltserver gemeinsam. Status, Laufzeit und Spielerzahl siehst du direkt. Beim Spielstart wird die passende Realmlist verwendet.
|
||||
- **Spielclient verwalten.** Verwende deinen vorhandenen Client oder lade ihn über den Manager herunter: etwa 43 GB, fortsetzbar und dateiweise geprüft. Verwaltete Clients erhalten Update-Hinweise; selbst veränderte Dateien werden nur mit deiner Zustimmung ersetzt.
|
||||
- **Serverupdates.** Der Manager sucht beim Start und alle fünf Minuten nach Updates. Ed25519-Signaturen schützen die Pakete. Vor dem Update wird eine Sicherung erstellt; eigene Änderungen bleiben erhalten und ein Rollback ist möglich.
|
||||
- **Sicherungen.** Sichere Charaktere, Konten und Einstellungen bei Bedarf oder automatisch vor Updates. Prüfen und Wiederherstellen geht direkt in der Anwendung.
|
||||
- **CoA, Wildcard und Warcraft Reborn.** Wähle den Realm unter Übersicht. Die Serverversion muss den gewählten Modus unterstützen. Beim ersten Wechsel entstehen eine eigene Welt und eine leere Charakterdatenbank. Konten werden geteilt, Charaktere und Moduleinstellungen bleiben getrennt. Ein laufender Server speichert die Charaktere und startet beim Wechsel neu. Schließe vorher den Client; bei geöffnetem Client wird die Realm-Auswahl erst beim nächsten Start über den Manager übernommen. Bots sind auf Wildcard und Warcraft Reborn nicht verfügbar; andere Zusatzmodule sind dort experimentell. Sicherungen und Datenbankupdates berücksichtigen alle angelegten Realms. CoA und Wildcard können gemeinsam laufen. [Wildcard](docs/de/WILDCARD.md) · [Warcraft Reborn](docs/de/WARCRAFT-REBORN.md).
|
||||
- **Begleiter-Bots.** Erstelle Bots mit passender Stufe, verfolge den Fortschritt, pausiere die Erstellung oder melde Bots ab. Entferne bei Bedarf eine bestimmte Anzahl, um den Server zu entlasten, und lege die Zahl beim Serverstart fest.
|
||||
- **Realmlists wechseln.** Speichere benannte Verbindungen wie Solo oder PTR und wähle sie neben der Spiel-Schaltfläche. Die zuvor verwendete Datei wird gesichert.
|
||||
- **Konten und Einstellungen.** Verwalte Namen, Passwörter und Zugriffsrechte. Für Server und Bots gibt es verständliche Optionen und Voreinstellungen, außerdem eine Serverkonsole und eine Spielerliste mit Kontoverwaltung.
|
||||
- **Module.** Aktiviere Zusatzfunktionen, öffne ihre Einstellungen oder die GitHub-Seite des jeweiligen Projekts.
|
||||
- **Mit Freunden spielen.** Erreichbarkeit prüfen, Firewall und UPnP einrichten und Verbindungen über LAN, direkte Adressen oder private Netze/Tailscale vorbereiten. Anleitungen und ein Paket mit Verbindungsdaten helfen deinen Freunden beim Beitritt.
|
||||
- **Probleme melden.** Bereite einen GitHub-Fehlerbericht mit automatisch eingetragenen Versionsangaben vor.
|
||||
- **Managerupdates.** Sie werden im Hintergrund geladen und beim Schließen installiert. Deine Server werden dabei weder angehalten noch verändert.
|
||||
- **Sprachen:** Englisch, Russisch, Deutsch, Französisch, Spanisch und vereinfachtes Chinesisch.
|
||||
|
||||
## Screenshots und Anleitungen
|
||||
|
||||
| Portable Characters — experimentell | Öffentliche Realms |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Begleiter | Sicherungen |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
[Zur Galerie](docs/de/SCREENSHOTS.md) · [Alle Anleitungen](docs/de/README.md). Die Galerie zeigt auch Einrichtung, Module, Spielclient, Verbindungen mit Freunden und die Übernahme eines vorhandenen Charakters.
|
||||
|
||||
## Installation
|
||||
|
||||
Lade das Installationsprogramm von der [Release-Seite](https://github.com/Corfirean/coa-server-manager/releases/latest) herunter und starte es. Für den Server brauchst du etwa 10 GB freien Speicher. Der Spielclient benötigt zusätzlich etwa 43 GB: Wähle einen vorhandenen Client oder lade ihn über den Manager.
|
||||
|
||||
## Änderungen
|
||||
|
||||
[Manager-Changelog](CHANGELOG.md) · [Server-Changelog](https://github.com/Corfirean/coa-server-build/blob/main/CHANGELOG.md). Manager und Server haben getrennte Versionsverläufe. Ein Managerupdate aktualisiert nicht den Server.
|
||||
|
||||
## Projektstatus
|
||||
|
||||
**In aktiver Entwicklung.** Fehler sind weiterhin möglich. Wenn du ein Problem findest, [erstelle bitte ein Issue](https://github.com/Corfirean/coa-server-manager/issues/new/choose).
|
||||
|
||||
### Bekannte Einschränkungen
|
||||
|
||||
- Der Ascension-Client 3.3.5 nimmt keine Zugangsdaten über die Kommandozeile an. Gib dein Passwort selbst ein oder verknüpfe ein bestehendes Konto.
|
||||
- Windows schützt vertrauliche Daten mit DPAPI (`CryptProtectData`) und zusätzlicher Entropie zur Domänentrennung. Linux/macOS verwenden Dateien mit den Zugriffsrechten `0600`.
|
||||
- Eine bestehende direkte TCP-Verbindung lässt sich nicht während der Sitzung auf einen Relay umstellen. Nach einem Verbindungsabbruch wird beim nächsten Beitritt automatisch auf die Ausweichroute zurückgegriffen.
|
||||
- Für sichere entfernte Importe portabler Charaktere muss `CharacterDatabase.WorkerThreads = 1` bleiben; das ist der Standardwert.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cargo test --workspace
|
||||
npx tsc --noEmit
|
||||
node tools/check-i18n.mjs
|
||||
npx vite
|
||||
cargo build -p coa-server-manager
|
||||
```
|
||||
|
||||
Die Befehle installieren Abhängigkeiten, führen Tests aus, prüfen Typen und Übersetzungen, starten die Browseransicht mit Testdaten und bauen die native Anwendung. Der Debug-Build verwendet `http://localhost:1420`.
|
||||
|
||||
Die Programmlogik liegt in `crates/coa-core`, Paket- und Signaturwerkzeuge in `crates/coa-release`, native Befehle in `src-tauri` und die Oberfläche in `src`.
|
||||
|
||||
`cargo run -p coa-core --example scan -- <folder>` prüft eine vorhandene Installation nur lesend. Verwende für Start-/Stopp-Tests ausschließlich eine entbehrliche Testkopie: `tools/make-fixture.ps1` erstellt `C:\games\coa-fixture` aus der unveränderten Paketdatenbank mit anderen Ports. Teste niemals an einem genutzten Server. `tests/e2e/cdp.mjs` steuert das echte Fenster über WebView2 mit `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222`.
|
||||
|
||||
## Lizenz
|
||||
|
||||
Der Manager ist freie Software unter der [GNU AGPL v3.0](LICENSE), Kennung `AGPL-3.0-only`.
|
||||
|
||||
- Der installierte Server basiert auf einem öffentlichen [AzerothCore-Fork](https://github.com/Corfirean/azerothcore-wotlk-coa). Für von MaNGOS übernommene Teile gilt GPL-2.0-or-later, für eigene AzerothCore-Teile AGPL-3.0. [mod-coa-playerbots](https://github.com/Corfirean/mod-coa-playerbots) steht unter AGPL-3.0. Die exakten Quellstände stehen im Paketmanifest, damit der passende Quellcode auffindbar bleibt.
|
||||
- Das mitgelieferte MySQL behält seine GPL-2.0-Lizenz; der Lizenztext liegt im Paket unter `Licenses`.
|
||||
- Die Spieldaten in `Data` (`dbc`, `maps`, `vmaps`, `mmaps`) stammen aus dem Client des eingestellten Ascension-Realms Conquest of Azeroth. Sie fallen **nicht** unter die Lizenz des Managers.
|
||||
@@ -0,0 +1,84 @@
|
||||
# CoA Server Manager
|
||||
|
||||
[English](README.md) · [Русский](README.ru.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · **Español** · [简体中文](README.zh-CN.md)
|
||||
|
||||
**Los personajes portátiles son una función experimental.** Sigue en desarrollo y puede contener errores.
|
||||
|
||||
Una aplicación para Windows con la que instalar y gestionar tu propio servidor de **Conquest of Azeroth**, basado en AzerothCore, en pocos pasos. No necesitas usar la consola, editar archivos de configuración a mano ni manejar programas de bases de datos. Creada con Tauri 2, Rust y React.
|
||||
|
||||

|
||||
|
||||
## Funciones
|
||||
|
||||
- **Personajes portátiles — experimental.** Guarda personajes y sus distintas versiones en el Manager, o convierte en portátil un personaje que ya tengas en un reino al que puedas acceder. [Ver la guía](docs/es/PORTABLE-CHARACTERS.md).
|
||||
- **Buscador de reinos.** Compara reinos públicos por modo de juego, nivel máximo, multiplicadores, módulos y población.
|
||||
- **Instalación sencilla.** Descarga un paquete de servidor firmado, comprueba los archivos, prepara la base de datos y crea tu primera cuenta. Las descargas se pueden reanudar. También puedes importar un servidor existente.
|
||||
- **Iniciar, detener y jugar.** Controla conjuntamente la base de datos y los servidores de autenticación y mundo. Consulta su estado, tiempo de actividad y jugadores conectados. Al abrir el juego se utiliza la realmlist correcta.
|
||||
- **Cliente de juego.** Usa el que ya tengas o descárgalo desde el Manager: unos 43 GB, con descarga reanudable y verificación de archivos. Los clientes gestionados reciben avisos de actualización; los archivos que hayas modificado solo se sustituyen con tu permiso.
|
||||
- **Actualizaciones del servidor.** Se buscan al iniciar y cada cinco minutos. Los paquetes se verifican mediante firmas Ed25519, se crea una copia antes de actualizar, se conservan tus cambios y es posible volver atrás.
|
||||
- **Copias de seguridad.** Guarda personajes, cuentas y ajustes cuando quieras o automáticamente antes de una actualización. Comprueba y restaura las copias desde la aplicación.
|
||||
- **CoA, Wildcard y Warcraft Reborn.** Elige el reino en Resumen. La versión del servidor debe admitir el modo seleccionado. El primer cambio crea un mundo independiente y una base de personajes vacía. Las cuentas son compartidas; los personajes y los ajustes de módulos se mantienen separados. Si el servidor está encendido, guarda los personajes y se reinicia al cambiar. Cierra el cliente antes del cambio; si está abierto, el nuevo reino se aplicará la próxima vez que lo abras desde el Manager. No hay bots en Wildcard ni en Warcraft Reborn; los demás módulos opcionales son experimentales allí. Las copias y las actualizaciones cubren todos los reinos creados. CoA y Wildcard pueden funcionar a la vez. [Wildcard](docs/es/WILDCARD.md) · [Warcraft Reborn](docs/es/WARCRAFT-REBORN.md).
|
||||
- **Bots compañeros.** Llena el mundo con bots de nivel adecuado, sigue su creación, ponla en pausa o desconecta los bots. Elimina una cantidad concreta para reducir la carga y decide cuántos se conectan al iniciar el servidor.
|
||||
- **Cambio de realmlist.** Guarda conexiones con nombres como Solo o PTR y selecciónalas junto al botón de jugar. El archivo anterior se guarda antes de sustituirlo.
|
||||
- **Cuentas y ajustes.** Gestiona nombres, contraseñas y permisos. Configura el servidor y los bots con opciones explicadas y ajustes predefinidos. También tienes una consola de servidor y una lista de jugadores con herramientas de cuentas.
|
||||
- **Módulos.** Activa funciones adicionales, cambia sus ajustes o abre el repositorio de sus autores desde la misma página.
|
||||
- **Jugar con amigos.** Comprueba si pueden acceder, configura el cortafuegos y UPnP, y prepara conexiones por LAN, dirección directa o red privada/Tailscale. Incluye guías y un paquete con los datos de conexión para compartir.
|
||||
- **Informar de problemas.** Prepara un informe para GitHub con las versiones ya rellenadas.
|
||||
- **Actualizaciones del Manager.** Se descargan en segundo plano y se instalan al cerrar la aplicación. No detienen ni modifican tus servidores.
|
||||
- **Idiomas:** inglés, ruso, alemán, francés, español y chino simplificado.
|
||||
|
||||
## Capturas y guías
|
||||
|
||||
| Personajes portátiles — experimental | Reinos públicos |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Compañeros | Copias de seguridad |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
[Galería completa](docs/es/SCREENSHOTS.md) · [Todas las guías](docs/es/README.md). También encontrarás la instalación, los módulos, el cliente, las conexiones con amigos y cómo convertir un personaje existente en portátil.
|
||||
|
||||
## Instalación
|
||||
|
||||
Descarga el instalador desde la [página de versiones](https://github.com/Corfirean/coa-server-manager/releases/latest) y ejecútalo. Necesitas unos 10 GB libres para el servidor. El cliente se instala por separado y ocupa unos 43 GB: indica tu carpeta existente o descárgalo con el Manager.
|
||||
|
||||
## Historial de cambios
|
||||
|
||||
[Cambios del Manager](CHANGELOG.md) · [Cambios del servidor](https://github.com/Corfirean/coa-server-build/blob/main/CHANGELOG.md). Sus versiones se siguen por separado. Actualizar el Manager no actualiza el servidor.
|
||||
|
||||
## Estado del proyecto
|
||||
|
||||
**En desarrollo activo.** Todavía puede haber errores. Si encuentras un problema, [abre una incidencia](https://github.com/Corfirean/coa-server-manager/issues/new/choose).
|
||||
|
||||
### Limitaciones conocidas
|
||||
|
||||
- El cliente Ascension 3.3.5 no acepta credenciales por línea de comandos. Introduce la contraseña manualmente o vincula una cuenta existente.
|
||||
- En Windows, los secretos se protegen con DPAPI (`CryptProtectData`) y entropía para separar dominios. Linux/macOS usan archivos con permisos `0600`.
|
||||
- Una conexión TCP directa ya establecida no puede pasar a un relé a mitad de sesión. Tras una interrupción, la ruta alternativa se selecciona automáticamente en la siguiente conexión.
|
||||
- Para importar personajes portátiles de forma remota con seguridad, mantén `CharacterDatabase.WorkerThreads = 1`, su valor predeterminado.
|
||||
|
||||
## Desarrollo
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cargo test --workspace
|
||||
npx tsc --noEmit
|
||||
node tools/check-i18n.mjs
|
||||
npx vite
|
||||
cargo build -p coa-server-manager
|
||||
```
|
||||
|
||||
Estos comandos instalan dependencias, ejecutan pruebas, comprueban tipos y traducciones, abren la interfaz en el navegador con datos de prueba y compilan la aplicación nativa. La versión de depuración utiliza `http://localhost:1420`.
|
||||
|
||||
La lógica está en `crates/coa-core`, las herramientas de empaquetado y firma en `crates/coa-release`, los comandos nativos en `src-tauri` y la interfaz en `src`.
|
||||
|
||||
`cargo run -p coa-core --example scan -- <folder>` inspecciona una instalación sin modificarla. Para probar el inicio y la parada, usa solo una copia desechable: `tools/make-fixture.ps1` crea `C:\games\coa-fixture` a partir de la base intacta del paquete y con puertos diferentes. No hagas estas pruebas en un servidor en uso. `tests/e2e/cdp.mjs` controla la ventana real mediante WebView2 con `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222`.
|
||||
|
||||
## Licencia
|
||||
|
||||
El Manager es software libre bajo [GNU AGPL v3.0](LICENSE), identificador `AGPL-3.0-only`.
|
||||
|
||||
- El servidor instalado procede de un [fork público de AzerothCore](https://github.com/Corfirean/azerothcore-wotlk-coa). Las partes heredadas de MaNGOS usan GPL-2.0-or-later y las propias de AzerothCore, AGPL-3.0. [mod-coa-playerbots](https://github.com/Corfirean/mod-coa-playerbots) utiliza AGPL-3.0. El manifiesto de cada paquete indica los commits exactos para localizar el código fuente correspondiente.
|
||||
- MySQL conserva su licencia GPL-2.0; el texto se incluye en la carpeta `Licenses` del paquete.
|
||||
- Los datos de juego de `Data` (`dbc`, `maps`, `vmaps`, `mmaps`) se extrajeron del cliente del antiguo reino Ascension Conquest of Azeroth. **No** están cubiertos por la licencia del Manager.
|
||||
@@ -0,0 +1,84 @@
|
||||
# CoA Server Manager
|
||||
|
||||
[English](README.md) · [Русский](README.ru.md) · [Deutsch](README.de.md) · **Français** · [Español](README.es.md) · [简体中文](README.zh-CN.md)
|
||||
|
||||
**Les personnages portables sont une fonctionnalité expérimentale.** Elle est encore en développement et peut présenter des bugs.
|
||||
|
||||
Une application Windows pour installer et gérer votre propre serveur **Conquest of Azeroth**, basé sur AzerothCore, en quelques étapes. Pas besoin de console, de modifier les fichiers de configuration à la main ou d'utiliser un outil de base de données. L'application utilise Tauri 2, Rust et React.
|
||||
|
||||

|
||||
|
||||
## Fonctionnalités
|
||||
|
||||
- **Personnages portables — expérimental.** Conservez vos personnages et leurs sauvegardes dans le Manager, ou rendez portable un personnage déjà présent sur un royaume auquel vous avez accès. [Voir le guide](docs/fr/PORTABLE-CHARACTERS.md).
|
||||
- **Découverte des royaumes.** Comparez les royaumes publics selon leur mode de jeu, niveau maximal, taux de progression, modules et population.
|
||||
- **Installation simplifiée.** Le Manager télécharge un paquet serveur signé, vérifie les fichiers, configure la base de données et crée votre premier compte. Les téléchargements peuvent reprendre après une interruption. Vous pouvez aussi importer un serveur existant.
|
||||
- **Démarrer, arrêter, jouer.** Une commande suffit pour gérer la base de données et les serveurs d'authentification et de monde. L'état, la durée de fonctionnement et les joueurs connectés restent visibles. Le lancement du jeu utilise la bonne realmlist.
|
||||
- **Gestion du client de jeu.** Utilisez votre client ou téléchargez-le depuis l'application : environ 43 Go, avec reprise et vérification des fichiers. Un client géré reçoit des mises à jour ; vos fichiers modifiés ne sont remplacés qu'avec votre accord.
|
||||
- **Mises à jour du serveur.** Recherche au démarrage et toutes les cinq minutes, vérification des signatures Ed25519, sauvegarde préalable, conservation des modifications personnelles et possibilité de revenir en arrière.
|
||||
- **Sauvegardes.** Enregistrez personnages, comptes et paramètres à la demande ou automatiquement avant les mises à jour. Vérifiez et restaurez les sauvegardes dans l'application.
|
||||
- **CoA, Wildcard et Warcraft Reborn.** Choisissez le royaume dans Vue d'ensemble. La version du serveur doit prendre en charge le mode choisi. Le premier changement crée un monde distinct et une base de personnages vide. Les comptes sont partagés, mais personnages et réglages des modules restent séparés. Un serveur en cours d'exécution sauvegarde les personnages puis redémarre lors du changement. Fermez le client avant de changer de royaume ; s'il est ouvert, le choix prendra effet au prochain lancement depuis le Manager. Les bots sont indisponibles sur Wildcard et Warcraft Reborn ; les autres modules optionnels y sont expérimentaux. Sauvegardes et mises à jour couvrent tous les royaumes créés. CoA et Wildcard peuvent fonctionner ensemble. [Wildcard](docs/fr/WILDCARD.md) · [Warcraft Reborn](docs/fr/WARCRAFT-REBORN.md).
|
||||
- **Bots compagnons.** Peuplez le monde avec des bots de niveau adapté, suivez leur création, mettez-la en pause ou déconnectez les bots. Retirez un nombre choisi de bots pour alléger le serveur et réglez combien se connectent au démarrage.
|
||||
- **Changement de realmlist.** Enregistrez des connexions nommées, comme Solo et PTR, et choisissez-les à côté du bouton de jeu. Le fichier précédent est sauvegardé avant remplacement.
|
||||
- **Comptes et réglages.** Gérez noms, mots de passe et droits d'accès. Configurez serveur et bots avec des options expliquées et des préréglages. Une console serveur et une liste des joueurs avec gestion des comptes sont également disponibles.
|
||||
- **Modules.** Activez les fonctions supplémentaires, modifiez leurs réglages ou consultez leur projet GitHub depuis une même page.
|
||||
- **Jouer avec des amis.** Vérifiez l'accessibilité du serveur, configurez pare-feu et UPnP, préparez une connexion en réseau local, directe ou via un réseau privé/Tailscale. Des guides et un paquet de connexion facilitent l'arrivée des amis.
|
||||
- **Signalement des problèmes.** Préparez un rapport GitHub avec les versions déjà renseignées.
|
||||
- **Mises à jour du Manager.** Téléchargées en arrière-plan, elles s'installent à la fermeture. Elles n'arrêtent pas vos serveurs et ne les modifient pas.
|
||||
- **Langues :** anglais, russe, allemand, français, espagnol et chinois simplifié.
|
||||
|
||||
## Captures et guides
|
||||
|
||||
| Personnages portables — expérimental | Royaumes publics |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Compagnons | Sauvegardes |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
[Galerie complète](docs/fr/SCREENSHOTS.md) · [Tous les guides](docs/fr/README.md). La galerie présente aussi l'installation, les modules, le client, les connexions entre amis et la création d'un personnage portable.
|
||||
|
||||
## Installation
|
||||
|
||||
Téléchargez et lancez l'installateur depuis les [publications GitHub](https://github.com/Corfirean/coa-server-manager/releases/latest). Prévoyez environ 10 Go libres pour le serveur. Le client de jeu est séparé et demande environ 43 Go : indiquez un dossier existant ou téléchargez-le avec le Manager.
|
||||
|
||||
## Historique des versions
|
||||
|
||||
[Changelog du Manager](CHANGELOG.md) · [Changelog du serveur](https://github.com/Corfirean/coa-server-build/blob/main/CHANGELOG.md). Leurs versions sont suivies séparément : mettre à jour le Manager ne met pas à jour le serveur.
|
||||
|
||||
## État du projet
|
||||
|
||||
**En développement actif.** Des bugs restent possibles. Si vous rencontrez un problème, [ouvrez une issue](https://github.com/Corfirean/coa-server-manager/issues/new/choose).
|
||||
|
||||
### Limites connues
|
||||
|
||||
- Le client Ascension 3.3.5 n'accepte pas les identifiants en ligne de commande. Saisissez votre mot de passe ou associez un compte existant.
|
||||
- Windows protège les secrets avec DPAPI (`CryptProtectData`) et une entropie de séparation de domaine. Sous Linux/macOS, ils sont conservés dans des fichiers aux permissions `0600`.
|
||||
- Une connexion TCP directe déjà établie ne peut pas basculer vers un relais en cours de session. Après une coupure, le repli automatique intervient à la connexion suivante.
|
||||
- Pour importer à distance des personnages portables en sécurité, conservez `CharacterDatabase.WorkerThreads = 1`, la valeur par défaut.
|
||||
|
||||
## Développement
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cargo test --workspace
|
||||
npx tsc --noEmit
|
||||
node tools/check-i18n.mjs
|
||||
npx vite
|
||||
cargo build -p coa-server-manager
|
||||
```
|
||||
|
||||
Ces commandes installent les dépendances, exécutent les tests, contrôlent les types et les traductions, ouvrent l'interface dans le navigateur avec des données de test, puis compilent l'application native. En mode debug, celle-ci utilise `http://localhost:1420`.
|
||||
|
||||
La logique se trouve dans `crates/coa-core`, les outils de paquetage et de signature dans `crates/coa-release`, les commandes natives dans `src-tauri` et l'interface dans `src`.
|
||||
|
||||
`cargo run -p coa-core --example scan -- <folder>` inspecte une installation sans la modifier. Pour tester démarrage et arrêt, utilisez uniquement une copie jetable : `tools/make-fixture.ps1` crée `C:\games\coa-fixture` depuis la base intacte du paquet, avec des ports distincts. Ne testez jamais sur un serveur utilisé par des joueurs. `tests/e2e/cdp.mjs` pilote la véritable fenêtre via WebView2 avec `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222`.
|
||||
|
||||
## Licence
|
||||
|
||||
Le Manager est un logiciel libre sous [GNU AGPL v3.0](LICENSE), identifiant `AGPL-3.0-only`.
|
||||
|
||||
- Le serveur installé provient d'un [fork public d'AzerothCore](https://github.com/Corfirean/azerothcore-wotlk-coa). Les parties héritées de MaNGOS relèvent de GPL-2.0-or-later et les parties propres à AzerothCore d'AGPL-3.0. [mod-coa-playerbots](https://github.com/Corfirean/mod-coa-playerbots) utilise AGPL-3.0. Les commits exacts figurent dans le manifeste de chaque paquet pour retrouver les sources correspondantes.
|
||||
- MySQL conserve sa licence GPL-2.0 ; son texte est fourni dans le dossier `Licenses` du paquet.
|
||||
- Les données de jeu dans `Data` (`dbc`, `maps`, `vmaps`, `mmaps`) proviennent du client de l'ancien royaume Ascension Conquest of Azeroth. Elles ne sont **pas** couvertes par la licence du Manager.
|
||||
@@ -1,5 +1,7 @@
|
||||
# CoA Server Manager
|
||||
|
||||
**English** · [Русский](README.ru.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md)
|
||||
|
||||
**Portable characters are experimental.** This feature is in active development and may contain bugs.
|
||||
|
||||
A Windows app that installs and runs your own **Conquest of Azeroth** server (AzerothCore) with a few clicks:
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# CoA Server Manager
|
||||
|
||||
[English](README.md) · **Русский** · [Deutsch](README.de.md) · [Français](README.fr.md) · [Español](README.es.md) · [简体中文](README.zh-CN.md)
|
||||
|
||||
**Переносимые персонажи — экспериментальная функция.** Она ещё развивается и может работать с ошибками.
|
||||
|
||||
Приложение для Windows, с которым можно установить и запустить свой сервер **Conquest of Azeroth** на базе AzerothCore за несколько шагов. Управляйте сервером через интерфейс, без консоли, ручного редактирования конфигов и отдельных программ для базы данных. Менеджер написан на Tauri 2, Rust и React.
|
||||
|
||||

|
||||
|
||||
## Возможности
|
||||
|
||||
- **Переносимые персонажи — экспериментально.** Храните персонажей и историю сохранений в менеджере, делайте переносимыми уже существующих персонажей с доступных вам реалмов. [Как это работает](docs/ru/PORTABLE-CHARACTERS.md).
|
||||
- **Каталог реалмов.** Сравнивайте публичные серверы по режиму игры, максимальному уровню, рейтам, модулям и числу игроков.
|
||||
- **Простая установка.** Менеджер скачивает подписанный пакет сервера, проверяет файлы, настраивает базу данных и создаёт первый аккаунт. Прерванную загрузку можно продолжить. Можно также подключить уже установленный сервер.
|
||||
- **Запуск, остановка и игра.** Управляйте базой данных и обоими серверными процессами одной кнопкой. На главном экране видны состояние, время работы и игроки онлайн. Кнопка запуска игры выбирает нужный realmlist.
|
||||
- **Игровой клиент.** Подключите свой клиент или скачайте его через менеджер — около 43 ГБ, с продолжением загрузки и проверкой файлов. Для управляемого клиента доступны обновления; ваши изменения заменяются только с вашего согласия.
|
||||
- **Обновления сервера.** Проверка выполняется при запуске и каждые пять минут. Пакеты защищены подписью Ed25519; перед установкой создаётся резервная копия. Пользовательские изменения сохраняются, предусмотрен откат.
|
||||
- **Резервные копии.** Сохраняйте персонажей, аккаунты и настройки вручную или автоматически перед обновлениями. Проверка и восстановление доступны в приложении.
|
||||
- **CoA, Wildcard и Warcraft Reborn.** Выбирайте реалм в разделе «Обзор». Нужна серверная сборка с поддержкой выбранного режима. При первом переключении создаются отдельный мир и пустая база персонажей. Аккаунты общие, персонажи и настройки модулей раздельные. Переключение работающего сервера сохраняет персонажей и перезапускает его. Закройте клиент перед переключением; если он открыт, новый реалм будет выбран при следующем запуске через менеджер. Боты недоступны в Wildcard и Warcraft Reborn, остальные дополнительные модули там экспериментальны. Копии и обновления охватывают все созданные реалмы. Одновременно можно запускать CoA и Wildcard. [Гайд по Wildcard](docs/ru/WILDCARD.md) · [Warcraft Reborn](docs/ru/WARCRAFT-REBORN.md).
|
||||
- **Компаньоны-боты.** Заселяйте мир ботами подходящего уровня, следите за созданием, приостанавливайте его, отключайте ботов или удаляйте нужное количество для снижения нагрузки. Можно задать число ботов при старте сервера.
|
||||
- **Список подключений.** Сохраняйте несколько realmlist с понятными названиями, например Solo и PTR, и переключайте их рядом с кнопкой игры. Предыдущий файл сохраняется.
|
||||
- **Аккаунты и настройки.** Управляйте именами, паролями и уровнями доступа. Настраивайте сервер и ботов через понятные параметры и готовые наборы; доступны консоль сервера и список игроков.
|
||||
- **Модули.** Включайте дополнительные возможности, меняйте их настройки и переходите в репозитории авторов с одной страницы.
|
||||
- **Игра с друзьями.** Проверка доступности, настройка брандмауэра и UPnP, подключение по локальной сети, напрямую или через частную сеть/Tailscale. Внутри есть пошаговые инструкции и пакет с данными подключения для друзей.
|
||||
- **Сообщения об ошибках.** Менеджер помогает подготовить issue на GitHub и подставляет версии программ.
|
||||
- **Обновление менеджера.** Загружается в фоне и устанавливается при закрытии приложения. Ваши серверы при этом не останавливаются и не изменяются.
|
||||
- **Языки:** английский, русский, немецкий, французский, испанский и упрощённый китайский.
|
||||
|
||||
## Скриншоты и гайды
|
||||
|
||||
| Переносимые персонажи — экспериментально | Публичные реалмы |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| Компаньоны | Резервные копии |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
[Все скриншоты](docs/ru/SCREENSHOTS.md) · [Гайды для игроков и владельцев серверов](docs/ru/README.md). В галерее также показаны установка, модули, клиент, подключение друзей и создание переносимого персонажа.
|
||||
|
||||
## Установка
|
||||
|
||||
Скачайте установщик со страницы [релизов](https://github.com/Corfirean/coa-server-manager/releases/latest) и запустите его. Для сервера нужно около 10 ГБ свободного места. Клиент устанавливается отдельно и занимает около 43 ГБ: укажите существующую папку или скачайте его через менеджер.
|
||||
|
||||
## Что изменилось
|
||||
|
||||
[Ченджлог менеджера](CHANGELOG.md) · [Ченджлог сервера](https://github.com/Corfirean/coa-server-build/blob/main/CHANGELOG.md). Это отдельные продукты с отдельной историей версий: обновление менеджера не обновляет сервер.
|
||||
|
||||
## Состояние проекта
|
||||
|
||||
**Проект активно развивается.** Ошибки ещё возможны. Если встретили проблему, [создайте issue](https://github.com/Corfirean/coa-server-manager/issues/new/choose).
|
||||
|
||||
### Известные ограничения
|
||||
|
||||
- Клиент Ascension 3.3.5 не принимает логин и пароль через командную строку. Введите пароль вручную или привяжите существующий аккаунт.
|
||||
- В Windows секреты защищены DPAPI (`CryptProtectData`) с отдельной энтропией домена. На Linux/macOS используются файлы с правами `0600`.
|
||||
- Уже открытое прямое TCP-соединение нельзя переключить на ретранслятор без разрыва. Резервный маршрут выбирается при следующем подключении.
|
||||
- Для безопасного удалённого импорта переносимых персонажей оставьте `CharacterDatabase.WorkerThreads = 1` — это значение по умолчанию.
|
||||
|
||||
## Разработка
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cargo test --workspace
|
||||
npx tsc --noEmit
|
||||
node tools/check-i18n.mjs
|
||||
npx vite
|
||||
cargo build -p coa-server-manager
|
||||
```
|
||||
|
||||
Команды устанавливают зависимости, запускают тесты, проверяют типы и переводы, открывают браузерный интерфейс с тестовыми данными и собирают нативное приложение. В отладочном режиме оно использует `http://localhost:1420`.
|
||||
|
||||
`crates/coa-core` содержит основную логику, `crates/coa-release` — упаковку и подписи, `src-tauri` — команды приложения, `src` — интерфейс.
|
||||
|
||||
Проверить существующую сборку без изменений можно командой `cargo run -p coa-core --example scan -- <folder>`. Для проверок запуска и остановки используйте только отдельную тестовую копию: `tools/make-fixture.ps1` создаёт `C:\games\coa-fixture` из чистой базы пакета, с другими портами. Не проводите такие проверки на сервере, которым пользуются игроки. `tests/e2e/cdp.mjs` управляет настоящим окном через WebView2 с `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222`.
|
||||
|
||||
## Лицензия
|
||||
|
||||
Менеджер — свободное ПО под [GNU AGPL v3.0](LICENSE), идентификатор `AGPL-3.0-only`.
|
||||
|
||||
- Устанавливаемый сервер основан на [форке AzerothCore](https://github.com/Corfirean/azerothcore-wotlk-coa): части, унаследованные от MaNGOS, используют GPL-2.0-or-later, собственные части AzerothCore — AGPL-3.0. Модуль [mod-coa-playerbots](https://github.com/Corfirean/mod-coa-playerbots) использует AGPL-3.0. Точные коммиты записываются в манифест пакета, чтобы можно было найти соответствующий исходный код.
|
||||
- MySQL распространяется по собственной лицензии GPL-2.0; её текст находится в папке `Licenses` пакета.
|
||||
- Данные игры в `Data` (`dbc`, `maps`, `vmaps`, `mmaps`) извлечены из клиента закрытого реалма Ascension Conquest of Azeroth. Лицензия менеджера на них **не распространяется**.
|
||||
@@ -0,0 +1,84 @@
|
||||
# CoA Server Manager
|
||||
|
||||
[English](README.md) · [Русский](README.ru.md) · [Deutsch](README.de.md) · [Français](README.fr.md) · [Español](README.es.md) · **简体中文**
|
||||
|
||||
**便携角色是一项实验性功能。** 目前仍在开发中,使用时可能遇到问题。
|
||||
|
||||
这是一款 Windows 应用,帮助你用几个简单步骤搭建和管理自己的 **Conquest of Azeroth** 服务器,服务端基于 AzerothCore。日常操作不需要使用控制台、手动修改配置文件或单独操作数据库。应用采用 Tauri 2、Rust 和 React 开发。
|
||||
|
||||

|
||||
|
||||
## 主要功能
|
||||
|
||||
- **便携角色(实验性)。** 在管理器中保存角色及其历史存档,也可以将有权访问的服务器上的现有角色转换为便携角色。[查看使用指南](docs/zh-CN/PORTABLE-CHARACTERS.md)。
|
||||
- **查找服务器。** 浏览公开服务器,对比游戏模式、等级上限、倍率、模块和在线人数。
|
||||
- **轻松安装。** 下载带签名的服务端安装包,逐一校验文件,配置数据库并创建首个账号。下载支持断点续传,也可以导入已有服务器。
|
||||
- **启动、停止和进入游戏。** 一处控制数据库、认证服务和世界服务,随时查看运行状态、运行时间和在线玩家。启动游戏时会自动使用对应的 realmlist。
|
||||
- **管理游戏客户端。** 指定现有客户端,或通过管理器下载约 43 GB 的客户端。下载可以续传,文件会经过校验。受管理的客户端可以检查更新;自行修改的文件只有在你同意后才会被替换。
|
||||
- **更新服务器。** 应用启动时及之后每五分钟检查一次更新。安装包通过 Ed25519 签名验证,更新前自动备份,保留用户修改,并支持回滚。
|
||||
- **备份与恢复。** 手动备份角色、账号和设置,或在更新前自动备份。在应用中即可校验和恢复。
|
||||
- **CoA、Wildcard 和 Warcraft Reborn。** 在概览中选择服务器模式,服务端版本必须支持所选模式。首次切换会创建独立的世界数据库和空的角色数据库。各模式共用账号,角色和模块设置相互独立。运行中切换会先保存角色,再重启服务器。切换前请关闭客户端;如果客户端仍开着,新选择会在下次通过管理器启动游戏时生效。Wildcard 和 Warcraft Reborn 不支持机器人,其他可选模块在这两种模式下也属于实验性支持。备份和数据库更新涵盖所有已创建的模式。目前支持 CoA 与 Wildcard 同时运行。[Wildcard 指南](docs/zh-CN/WILDCARD.md) · [Warcraft Reborn 指南](docs/zh-CN/WARCRAFT-REBORN.md)。
|
||||
- **伙伴机器人。** 创建等级合适的机器人并查看进度,暂停创建、让机器人下线,或移除指定数量以减轻服务器负担。也可以设置开服时登录的机器人数量。
|
||||
- **切换 realmlist。** 为不同连接保存名称,例如 Solo 和 PTR,在进入游戏按钮旁直接切换。替换前会保存原文件。
|
||||
- **账号和设置。** 管理账号名称、密码和权限,通过说明清楚的选项及预设调整服务器和机器人。还提供世界服务器控制台,以及带账号管理功能的玩家列表。
|
||||
- **模块管理。** 在同一页面启用可选功能、调整设置,或打开相应项目的 GitHub 页面。
|
||||
- **好友联机。** 检查服务器是否可达,配置防火墙和 UPnP,支持局域网、直接连接及私有网络/Tailscale。应用提供分步说明,还能准备可分享的连接资料包。
|
||||
- **报告问题。** 帮你准备 GitHub 问题报告,并自动填写版本信息。
|
||||
- **管理器自身更新。** 在后台下载,关闭应用时安装。更新管理器不会停止或修改你的服务器。
|
||||
- **支持语言:** 英语、俄语、德语、法语、西班牙语和简体中文。
|
||||
|
||||
## 截图与指南
|
||||
|
||||
| 便携角色(实验性) | 公开服务器 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
| 伙伴机器人 | 备份 |
|
||||
|---|---|
|
||||
|  |  |
|
||||
|
||||
[查看完整截图](docs/zh-CN/SCREENSHOTS.md) · [玩家与服主指南](docs/zh-CN/README.md)。图库还展示了初始设置、模块、客户端、朋友联机,以及将现有角色转换为便携角色的流程。
|
||||
|
||||
## 安装
|
||||
|
||||
从 [GitHub 发布页](https://github.com/Corfirean/coa-server-manager/releases/latest)下载安装程序并运行。服务器约需 10 GB 可用空间。游戏客户端单独安装,约占 43 GB;你可以指定已有客户端目录,也可以交给管理器下载。
|
||||
|
||||
## 更新记录
|
||||
|
||||
[管理器更新记录](CHANGELOG.md) · [服务器更新记录](https://github.com/Corfirean/coa-server-build/blob/main/CHANGELOG.md)。两者的版本和更新记录分别维护。更新管理器不会同时更新服务器。
|
||||
|
||||
## 项目状态
|
||||
|
||||
**正在积极开发中。** 仍可能存在问题。如果遇到故障,请[提交 issue](https://github.com/Corfirean/coa-server-manager/issues/new/choose)。
|
||||
|
||||
### 已知限制
|
||||
|
||||
- Ascension 3.3.5 客户端不支持通过命令行传入账号和密码。请自行输入密码,或关联现有账号。
|
||||
- Windows 使用 DPAPI(`CryptProtectData`)及用于域隔离的额外熵来保护机密数据。Linux/macOS 使用权限为 `0600` 的文件。
|
||||
- 已建立的直连 TCP 会话无法在不中断连接的情况下切换至中继。连接中断后,会在下一次加入服务器时自动尝试备用路径。
|
||||
- 为保证远程导入便携角色的安全,请保留默认设置 `CharacterDatabase.WorkerThreads = 1`。
|
||||
|
||||
## 开发
|
||||
|
||||
```sh
|
||||
npm install
|
||||
cargo test --workspace
|
||||
npx tsc --noEmit
|
||||
node tools/check-i18n.mjs
|
||||
npx vite
|
||||
cargo build -p coa-server-manager
|
||||
```
|
||||
|
||||
以上命令依次安装依赖、运行测试、检查类型与翻译、启动使用模拟数据的浏览器界面,以及编译原生应用。调试版应用连接 `http://localhost:1420`。
|
||||
|
||||
`crates/coa-core` 存放核心逻辑,`crates/coa-release` 提供打包和签名工具,`src-tauri` 处理原生命令,`src` 存放界面代码。
|
||||
|
||||
`cargo run -p coa-core --example scan -- <folder>` 可以只读检查现有安装。启动和停止测试必须使用可丢弃的独立副本:`tools/make-fixture.ps1` 会从安装包的原始数据库创建 `C:\games\coa-fixture`,并使用不同端口。不要在正在供玩家使用的服务器上进行此类测试。`tests/e2e/cdp.mjs` 可通过 WebView2 控制真实窗口,需设置 `WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=9222`。
|
||||
|
||||
## 许可
|
||||
|
||||
管理器是自由软件,采用 [GNU AGPL v3.0](LICENSE),标识为 `AGPL-3.0-only`。
|
||||
|
||||
- 所安装的服务器来自公开的 [AzerothCore 分支](https://github.com/Corfirean/azerothcore-wotlk-coa)。继承自 MaNGOS 的部分采用 GPL-2.0-or-later,AzerothCore 自有部分采用 AGPL-3.0。[mod-coa-playerbots](https://github.com/Corfirean/mod-coa-playerbots) 采用 AGPL-3.0。每个安装包的清单都记录了准确的提交版本,便于查找对应源码。
|
||||
- 随附的 MySQL 保留其 GPL-2.0 许可,许可文本位于安装包的 `Licenses` 目录。
|
||||
- `Data` 中的游戏数据(`dbc`、`maps`、`vmaps`、`mmaps`)提取自已关闭的 Ascension Conquest of Azeroth 服务器客户端,**不属于**管理器许可的授权范围。
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Advanced settings cover creature melee damage, spell damage and health by rank
|
||||
---
|
||||
Configure normal, elite, rare, rare elite and world boss creatures separately. Existing normal-creature settings now identify their scope clearly. Changes persist in the server template and require a world server restart.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Internet sharing accepts existing port forwards to the same server
|
||||
---
|
||||
When a router rejects an already configured port forward, the Manager checks its destination. Matching enabled rules are accepted; conflicting rules report the affected TCP port and remain untouched.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Choose Parallel or Global level scaling in module settings
|
||||
---
|
||||
Content Scaling progression can now be set to Parallel (Classic over 1 to the level cap, TBC and WotLK over 45 to the cap) or Global (all expansions over 1 to the cap) without editing the config file by hand.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: changed
|
||||
audience: admins
|
||||
title: The Auction House Bot card has a single switch
|
||||
---
|
||||
The separate "Post auctions" option is gone; turning the module on is enough. A server build with the single-switch Auction House Bot is needed, and an old EnableSeller line in the config is ignored.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: players
|
||||
title: More character and server browser controls now use your selected language
|
||||
---
|
||||
Completed missing German, French, Spanish and Simplified Chinese labels and improved Chinese font support.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Configure the day and night cycle from the module settings
|
||||
---
|
||||
TimeIsTime now has a module card, an on/off switch and settings translated into all six Manager languages.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: A stalled database inspection no longer blocks server maintenance indefinitely
|
||||
---
|
||||
Update checks limit how long their database client can run and release the maintenance lock after a timeout. The Manager stops only its inspection client, keeps the database server running and reports that migration history could not be checked.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Server owners can allow portable characters per realm
|
||||
---
|
||||
Portable admission is off until enabled by the realm owner. Export and import requests enforce the saved choice.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Allowing portable characters also prepares the server for portable sessions
|
||||
---
|
||||
Stop the server before enabling access for the first time. The Manager enables portable sessions in the effective module configuration and preserves the required database setting across restarts. Existing configuration is backed up.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: players
|
||||
title: Portable characters open again after using an experimental Manager
|
||||
---
|
||||
Preserves saved characters and collections when opening a portable store upgraded by an experimental Manager.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Managers stay compatible with the public connection service
|
||||
---
|
||||
Coordinator authentication no longer changes when portable messages change. The publishing card shows game relay status separately.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Relay connections recover and use the ports of the installed server
|
||||
---
|
||||
Dropped or unresponsive relay links reconnect automatically. Abandoned game connections close, and tunnels refresh when local ports become available or change. Custom authentication ports are respected.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Portable progress resumes after reconnecting or restarting the Manager
|
||||
---
|
||||
The player Manager now saves remote baselines, checkpoints and logout progress durably. Repeated logins stay monitored, and transfer retries preserve the same character and session.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: known-issue
|
||||
audience: admins
|
||||
title: Older copies on another host may require maintenance before returning
|
||||
---
|
||||
Experimental portable play blocks an outdated existing copy on another host. Automatic refresh while that remote worldserver runs is not available. Your canonical character remains saved in your Manager.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: client
|
||||
type: changed
|
||||
audience: players
|
||||
title: Install the matching CoA Custom 1.9.1 client files
|
||||
---
|
||||
The races installer verifies and installs all five client archives, including the new races and appearance corrections.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: changed
|
||||
audience: players
|
||||
title: Install the CoA Custom 1.9.2 client patches
|
||||
---
|
||||
Installs the updated Hozen and Centaur assets and quest safety addon. Existing CoA Custom 1.9.1 installations remain recognized when updating or removing patches.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Report Content Scaling problems directly to its repository
|
||||
---
|
||||
The report form now includes a separate Content Scaling destination.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Send custom race problem reports directly to their maintainer
|
||||
---
|
||||
The report form now includes Custom Races as a separate destination.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Internet sharing restores the LAN address after server restarts
|
||||
---
|
||||
Friends on the local network receive the server's LAN address and network mask while internet players keep using its public address.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Server updates preserve the order of database migrations added during merges
|
||||
---
|
||||
Older database snapshots no longer run after newer updates when preparing server packages. This prevents outdated Manastorm reward ranges from overwriting newer progression data.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Public listing offers every realm that starts together, not just the main one
|
||||
---
|
||||
With Wildcard or Warcraft Reborn started next to Conquest of Azeroth, Settings now has one listing block per realm, each with its own name, description and access options.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: changed
|
||||
audience: admins
|
||||
title: Public listing is a short list of realms with a window for the details
|
||||
---
|
||||
Settings shows one line per realm with its state, and publishing opens a window. Publish all realms at once. Account creation and portable characters are chosen before publishing; portable characters are on by default.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Configure profession and defense skill gains in server settings
|
||||
---
|
||||
Advanced rate settings now include prospecting and milling skill gains, plus the points gained from crafting, defense and gathering.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Find and edit CoA options in the existing server settings
|
||||
---
|
||||
Search includes the separate CoA configuration, including legacy mount flight. Changes go to the correct file; realm profile controls stay managed by the Manager.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Existing unsupported character records no longer block unrelated server updates
|
||||
---
|
||||
Updates compare each affected character with its saved pre-update state. Unchanged older problems are reported as warnings without changing characters. Newly missing race/class data still blocks the update and triggers recovery; schema and identity checks remain enforced.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Recovery can stop an unfinished world startup after restoring an older launcher
|
||||
---
|
||||
If the world console is unavailable, stop only the verified recorded world process. Keep MySQL running if ownership or shutdown cannot be confirmed.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: New server installation waits for slower database startup
|
||||
---
|
||||
Allow up to five minutes for MySQL to become ready. Failed installations stop their temporary database before cleaning up its files.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Control portable characters and keep Manager running in the system tray
|
||||
---
|
||||
Realm access settings can enable portable characters. Closing the window keeps Manager in the tray, with commands to reopen or exit. Offline realm messages explain why players cannot connect.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: changed
|
||||
audience: admins
|
||||
title: Settings group updates, startup and maintenance with translated release history
|
||||
---
|
||||
Server and Manager keep separate update controls. Read their published changes in one place, including translations of the latest releases in all six interface languages. Saved notes remain available offline. Find public realm controls under Play with friends.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: changed
|
||||
audience: admins
|
||||
title: Update and launch without updating share a divided button
|
||||
---
|
||||
Server and game client actions each use two adjacent segments of equal height. Both actions remain visible and keep their own availability checks.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Simultaneous update checks share results or wait their turn
|
||||
---
|
||||
Repeated checks no longer compete with another foreground or background check in the same Manager window. Maintenance and recovery locks remain enforced.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Maximizing the window keeps text and controls at a comfortable size
|
||||
---
|
||||
The interface no longer enlarges with window width. System display scaling and browser zoom still work, and large action buttons use more compact spacing.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Slow server startup no longer triggers an early update rollback
|
||||
---
|
||||
World startup now allows up to 25 minutes with either bot system and waits for both game and console ports within one deadline. Servers that stop during startup still fail immediately.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Account creation verifies a lost console reply against the requested login
|
||||
---
|
||||
Confirm the requested credentials after a lost reply, block creation during another server action, and explicitly report accounts whose administrator access could not be confirmed.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Abandoned game relay tunnels close their local connection immediately
|
||||
---
|
||||
Dropping a tunnel shuts down its shared socket directly instead of relying only on a background message, releasing its local read and write connection.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Keep disabled graphics components disabled during client updates
|
||||
---
|
||||
Client settings can disable DXVK and graphics enhancements. Existing launcher DISABLED files are respected.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Force-stop an unresponsive world after a failed normal shutdown
|
||||
---
|
||||
Confirm the risk of unsaved progress before stopping verified server processes. Shared databases stay running until every owned world has stopped.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Crash history includes reports from every world and Windows text files
|
||||
---
|
||||
Create and verify the crash report folder before native startup, and include reports from simultaneous worlds and files containing Windows ANSI text.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Show the online bot count for Playerbots on the overview
|
||||
---
|
||||
The overview displays bot population for both supported bot systems.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: added
|
||||
audience: admins
|
||||
title: Disable public server browsing in Settings
|
||||
---
|
||||
Turn off public server browsing to block catalog list and detail requests. When browsing is enabled, load the list with Refresh.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Keep deliberate stops and update shutdowns from triggering crash restart
|
||||
---
|
||||
Status checks no longer forget a stop request while the world is still shutting down. Update validation and recovery do not trigger automatic crash restarts.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Show available update notes even when release history is cached
|
||||
---
|
||||
Fetch the release notes for the offered update when they are missing from the cached history, including when an update becomes available while the history is open.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Server startup waits for a responsive console before reporting success
|
||||
---
|
||||
An open port no longer marks a loading world as ready. Account creation checks that the console processes commands before sending a create request.
|
||||
@@ -0,0 +1,7 @@
|
||||
---
|
||||
area: manager
|
||||
type: fixed
|
||||
audience: admins
|
||||
title: Restore the existing Manager window when launching it again
|
||||
---
|
||||
Choose whether closing the window exits the Manager or minimizes it to the tray. Tray actions restore the window on the UI thread.
|
||||
+266
-1
@@ -112,6 +112,8 @@
|
||||
"Corfirean/coa-server-manager:20261016-preserve-core-migration-history.md",
|
||||
"Corfirean/coa-server-manager:20261016-shared-auth-migration-history.md",
|
||||
"Corfirean/coa-server-manager:20261016-update-compatibility-wording.md",
|
||||
"Corfirean/coa-server-manager:20261017-creature-difficulty-rates.md",
|
||||
"Corfirean/coa-server-manager:20261017-existing-router-mapping.md",
|
||||
"Corfirean/coa-server-manager:20261018-custom-races-client.md",
|
||||
"Corfirean/coa-server-manager:20261019-portable-stop-diagnostics.md",
|
||||
"Corfirean/coa-server-manager:20261019-remote-character-class-names.md",
|
||||
@@ -120,7 +122,59 @@
|
||||
"Corfirean/coa-server-manager:20261021-portable-experimental.md",
|
||||
"Corfirean/coa-server-manager:20261021-scaling-health-settings.md",
|
||||
"Corfirean/coa-server-manager:20261021-warcraft-reborn-realm.md",
|
||||
"Corfirean/coa-server-manager:20261022-custom-races-client.md"
|
||||
"Corfirean/coa-server-manager:20261022-custom-races-client.md",
|
||||
"Corfirean/coa-server-manager:20261022-scaling-progression-modes.md",
|
||||
"Corfirean/coa-server-manager:20261023-ah-bot-single-switch.md",
|
||||
"Corfirean/coa-server-manager:20261023-interface-language-coverage.md",
|
||||
"Corfirean/coa-server-manager:20261023-online-players-exclude-squid-bots.md",
|
||||
"Corfirean/coa-server-manager:20261023-shared-addons-links.md",
|
||||
"Corfirean/coa-server-manager:20261023-time-is-time-settings.md",
|
||||
"Corfirean/coa-server-manager:20261023-update-check-database-timeout.md",
|
||||
"Corfirean/coa-server-manager:20261024-portable-admission.md",
|
||||
"Corfirean/coa-server-manager:20261024-portable-owner-core-activation.md",
|
||||
"Corfirean/coa-server-manager:20261024-portable-schema-nine.md",
|
||||
"Corfirean/coa-server-manager:20261024-relay-authentication.md",
|
||||
"Corfirean/coa-server-manager:20261024-relay-recovery.md",
|
||||
"Corfirean/coa-server-manager:20261024-remote-progress.md",
|
||||
"Corfirean/coa-server-manager:20261024-remote-refresh.md",
|
||||
"Corfirean/coa-server-manager:20261025-custom-races-191.md",
|
||||
"Corfirean/coa-server-manager:20261026-custom-races-192.md",
|
||||
"Corfirean/coa-server-manager:20261027-content-scaling-reports.md",
|
||||
"Corfirean/coa-server-manager:20261027-custom-races-reports.md",
|
||||
"Corfirean/coa-server-manager:20261027-internet-lan-address.md",
|
||||
"Corfirean/coa-server-manager:20261027-merged-database-migration-order.md",
|
||||
"Corfirean/coa-server-manager:20261027-per-race-server-settings.md",
|
||||
"Corfirean/coa-server-manager:20261027-public-list-shows-every-running-realm.md",
|
||||
"Corfirean/coa-server-manager:20261027-public-listing-window.md",
|
||||
"Corfirean/coa-server-manager:20261027-skill-gain-controls.md",
|
||||
"Corfirean/coa-server-manager:20261027-start-checks-the-realm-class-setting.md",
|
||||
"Corfirean/coa-server-manager:20261027-start-wildcard-and-reborn-with-coa.md",
|
||||
"Corfirean/coa-server-manager:20261028-coa-config-search.md",
|
||||
"Corfirean/coa-server-manager:20261028-existing-character-update-validation.md",
|
||||
"Corfirean/coa-server-manager:20261028-legacy-world-shutdown.md",
|
||||
"Corfirean/coa-server-manager:20261028-mysql-install-startup.md",
|
||||
"Corfirean/coa-server-manager:20261028-portable-settings-and-tray.md",
|
||||
"Corfirean/coa-server-manager:20261028-settings-release-history.md",
|
||||
"Corfirean/coa-server-manager:20261028-split-update-actions.md",
|
||||
"Corfirean/coa-server-manager:20261028-update-check-queue.md",
|
||||
"Corfirean/coa-server-manager:20261028-window-density.md",
|
||||
"Corfirean/coa-server-manager:20261029-slow-world-startup.md",
|
||||
"Corfirean/coa-server-manager:20261030-account-console-confirmation.md",
|
||||
"Corfirean/coa-server-manager:20261030-close-abandoned-relay-sockets.md",
|
||||
"Corfirean/coa-server-manager:20261030-disabled-client-renderers.md",
|
||||
"Corfirean/coa-server-manager:20261030-force-stop-unresponsive-world.md",
|
||||
"Corfirean/coa-server-manager:20261030-native-crash-reports.md",
|
||||
"Corfirean/coa-server-manager:20261030-playerbots-overview-count.md",
|
||||
"Corfirean/coa-server-manager:20261030-public-server-browsing-control.md",
|
||||
"Corfirean/coa-server-manager:20261030-stop-does-not-trigger-auto-restart.md",
|
||||
"Corfirean/coa-server-manager:20261030-update-history-target-cache.md",
|
||||
"Corfirean/coa-server-manager:20261030-wait-for-world-console-readiness.md",
|
||||
"Corfirean/coa-server-manager:20261030-window-lifecycle.md",
|
||||
"Corfirean/coa-server-manager:20261031-dungeon-clear-dependency.md",
|
||||
"Corfirean/coa-server-manager:20261031-launcher-diagnostics-export.md",
|
||||
"Corfirean/coa-server-manager:20261031-server-logging-controls.md",
|
||||
"Corfirean/coa-server-manager:20261031-validation-bot-population.md",
|
||||
"Corfirean/coa-server-manager:20261031-validation-mysql-reuse.md"
|
||||
],
|
||||
"releases": [
|
||||
{
|
||||
@@ -623,6 +677,217 @@
|
||||
"links": [
|
||||
"[Guides and screenshots](https://github.com/Corfirean/coa-server-manager/blob/master/docs/README.md)"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "v0.7.2",
|
||||
"version": "0.7.2",
|
||||
"date": "2026-10-23",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.7.2",
|
||||
"notes": [
|
||||
"Advanced settings cover creature melee damage, spell damage and health by rank",
|
||||
"Internet sharing accepts existing port forwards to the same server",
|
||||
"The Auction House Bot card has a single switch",
|
||||
"More character and server browser controls now use your selected language",
|
||||
"Online player counts correctly separate random bots from players",
|
||||
"Custom races work with shared addon folders on another drive",
|
||||
"Configure the day and night cycle from the module settings",
|
||||
"A stalled database inspection no longer blocks server maintenance indefinitely"
|
||||
],
|
||||
"commit": "4d59ce8cb1d4abd7f4992370e9a7ba4dc6bd02f7",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.7.1...v0.7.2"
|
||||
},
|
||||
{
|
||||
"tag": "v0.7.3",
|
||||
"version": "0.7.3",
|
||||
"date": "2026-10-23",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.7.3",
|
||||
"notes": [
|
||||
"Portable characters open again after using an experimental Manager"
|
||||
],
|
||||
"commit": "2715170622665f62c396fe1a79f7ef6504713ef1",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
],
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.7.2...v0.7.3"
|
||||
},
|
||||
{
|
||||
"tag": "v0.8.0",
|
||||
"version": "0.8.0",
|
||||
"date": "2026-10-24",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.8.0",
|
||||
"notes": [
|
||||
"Server owners can allow portable characters per realm",
|
||||
"Allowing portable characters also prepares the server for portable sessions",
|
||||
"Managers stay compatible with the public connection service",
|
||||
"Relay connections recover and use the ports of the installed server",
|
||||
"Portable progress resumes after reconnecting or restarting the Manager",
|
||||
"Older copies on another host may require maintenance before returning"
|
||||
],
|
||||
"commit": "b00e732c7f53f33680aaf6bd520da64b70b26a5a",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.7.3...v0.8.0",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "v0.8.1",
|
||||
"version": "0.8.1",
|
||||
"date": "2026-10-25",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.8.1",
|
||||
"notes": [
|
||||
"Install the matching CoA Custom 1.9.1 client files"
|
||||
],
|
||||
"commit": "02e3a9e4e66870a627feeb0b4d3ec2d75a23973d",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.8.0...v0.8.1",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "v0.8.2",
|
||||
"version": "0.8.2",
|
||||
"date": "2026-10-27",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.8.2",
|
||||
"notes": [
|
||||
"Installs the verified CoA Custom 1.9.2 client patches and quest safety addon, while recognizing existing 1.9.1 installations",
|
||||
"Server updates preserve the order of database migrations introduced during merges, preventing older snapshots from overwriting newer progression"
|
||||
],
|
||||
"commit": "81b040ef02763c7cf7a3a3da1424b73177e9d433",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.8.1...v0.8.2",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "v0.9.0",
|
||||
"version": "0.9.0",
|
||||
"date": "2026-10-28",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.0",
|
||||
"notes": [
|
||||
"Choose Parallel or Global level scaling in module settings",
|
||||
"Report Content Scaling problems directly to its repository",
|
||||
"Send custom race problem reports directly to their maintainer",
|
||||
"Internet sharing restores the LAN address after server restarts",
|
||||
"Configure custom race availability for new characters",
|
||||
"Public listing offers every realm that starts together, not just the main one",
|
||||
"Public listing is a short list of realms with a window for the details",
|
||||
"Configure profession and defense skill gains in server settings",
|
||||
"Starting a server sets the character classes of the realm chosen on Overview",
|
||||
"Start Wildcard and Warcraft Reborn together with Conquest of Azeroth",
|
||||
"Control portable characters and keep Manager running in the system tray"
|
||||
],
|
||||
"commit": "dc97095bd6b36d9b5111f04f0cfd88e1fb2ab1f1",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.8.2...v0.9.0",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"version": "0.9.1",
|
||||
"tag": "v0.9.1",
|
||||
"date": "2026-10-28",
|
||||
"notes": [
|
||||
"Existing unsupported character records no longer block unrelated server updates"
|
||||
],
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
],
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.1",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.9.0...v0.9.1",
|
||||
"commit": "8bbf16ed4f14e9b78c680783e4be5d8c580cc06b"
|
||||
},
|
||||
{
|
||||
"tag": "v0.9.2",
|
||||
"version": "0.9.2",
|
||||
"date": "2026-10-28",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.2",
|
||||
"notes": [
|
||||
"New server installation waits for slower database startup",
|
||||
"Find and edit CoA options in the existing server settings",
|
||||
"Simultaneous update checks share results or wait their turn",
|
||||
"Recovery can stop an unfinished world startup after restoring an older launcher"
|
||||
],
|
||||
"commit": "2d9c6a65bbb1e296dc919e4c9e5785ed9d4a070b",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.9.1...v0.9.2",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "v0.9.3",
|
||||
"version": "0.9.3",
|
||||
"date": "2026-10-29",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.3",
|
||||
"notes": [
|
||||
"Slow server startup no longer triggers an early update rollback"
|
||||
],
|
||||
"commit": "e7b76137d1b22a7596c2b1fa05ca9d12403b99d3",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.9.2...v0.9.3",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "v0.9.4",
|
||||
"version": "0.9.4",
|
||||
"date": "2026-10-29",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.4",
|
||||
"notes": [
|
||||
"Settings group updates, startup and maintenance with translated release history",
|
||||
"Update and launch without updating share a divided button",
|
||||
"Maximizing the window keeps text and controls at a comfortable size"
|
||||
],
|
||||
"commit": "c004b90c54fa358b3037c56b093cf019dfcf02b8",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.9.3...v0.9.4",
|
||||
"experimentalFeatures": [
|
||||
"portable-characters"
|
||||
]
|
||||
},
|
||||
{
|
||||
"tag": "v0.9.5",
|
||||
"version": "0.9.5",
|
||||
"date": "2026-10-30",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.5",
|
||||
"notes": [
|
||||
"Account creation verifies a lost console reply against the requested login",
|
||||
"Abandoned game relay tunnels close their local connection immediately",
|
||||
"Keep disabled graphics components disabled during client updates",
|
||||
"Force-stop an unresponsive world after a failed normal shutdown",
|
||||
"Crash history includes reports from every world and Windows text files",
|
||||
"Show the online bot count for Playerbots on the overview",
|
||||
"Disable public server browsing in Settings",
|
||||
"Keep deliberate stops and update shutdowns from triggering crash restart",
|
||||
"Server startup waits for a responsive console before reporting success",
|
||||
"Restore the existing Manager window when launching it again"
|
||||
],
|
||||
"commit": "642da8fdc1a0165aea74fbd1ab1b46933fc7e589",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.9.4...v0.9.5"
|
||||
},
|
||||
{
|
||||
"tag": "v0.9.7",
|
||||
"version": "0.9.7",
|
||||
"date": "2026-10-30",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.7",
|
||||
"notes": [
|
||||
"Show available update notes even when release history is cached"
|
||||
],
|
||||
"commit": "ba5816e3aea471d5b870e5f0db0b03920c071ed3",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.9.5...v0.9.7"
|
||||
},
|
||||
{
|
||||
"tag": "v0.9.8",
|
||||
"version": "0.9.8",
|
||||
"date": "2026-11-01",
|
||||
"url": "https://github.com/Corfirean/coa-server-manager/releases/tag/v0.9.8",
|
||||
"notes": [
|
||||
"Dungeon Clear can be enabled when SQUID bots are active",
|
||||
"Diagnostic exports include launcher stages and lock owner records",
|
||||
"Choose normal logs, errors only or no server logs in maintenance settings",
|
||||
"Update checks temporarily suspend bot population and restore your configuration",
|
||||
"Update validation keeps MySQL running between compatible checks"
|
||||
],
|
||||
"commit": "076fba185c4d96d74f7258d6ca0cae6a4ae4a613",
|
||||
"compare": "https://github.com/Corfirean/coa-server-manager/compare/v0.9.7...v0.9.8"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -15,6 +15,15 @@ pub const MAX_USERNAME: usize = 16;
|
||||
pub const MIN_PASSWORD: usize = 8;
|
||||
pub const MAX_PASSWORD: usize = 16;
|
||||
pub const MAX_CHARACTERS: usize = 200;
|
||||
pub const MAX_SESSION_BYTES: usize = 12 * 1024 * 1024;
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum SessionMessageKind {
|
||||
Started,
|
||||
Checkpoint,
|
||||
Collection,
|
||||
}
|
||||
|
||||
/// A game account name: 3 to 16 ASCII letters, digits or `_`, not beginning or ending with `_`.
|
||||
pub fn valid_username(s: &str) -> bool {
|
||||
@@ -109,6 +118,15 @@ pub enum Request {
|
||||
total_size: usize,
|
||||
collections: BTreeMap<String, Vec<u32>>,
|
||||
},
|
||||
OwnedTransferOffer {
|
||||
transfer_id: Uuid,
|
||||
character_id: Uuid,
|
||||
ruleset: String,
|
||||
canonical_revision: u64,
|
||||
content_hash: String,
|
||||
total_size: usize,
|
||||
collections: BTreeMap<String, Vec<u32>>,
|
||||
},
|
||||
/// A chunk of the encoded portable character snapshot payload.
|
||||
TransferChunk {
|
||||
transfer_id: Uuid,
|
||||
@@ -128,6 +146,24 @@ pub enum Request {
|
||||
transfer_id: Uuid,
|
||||
character_id: Uuid,
|
||||
},
|
||||
SessionPoll {
|
||||
character_id: Uuid,
|
||||
session_id: Uuid,
|
||||
},
|
||||
SessionRead {
|
||||
character_id: Uuid,
|
||||
session_id: Uuid,
|
||||
message_hash: String,
|
||||
offset: usize,
|
||||
},
|
||||
SessionAck {
|
||||
character_id: Uuid,
|
||||
session_id: Uuid,
|
||||
message_hash: String,
|
||||
offset: usize,
|
||||
total_size: usize,
|
||||
data: String,
|
||||
},
|
||||
/// Phase 13: Request a short-lived relay allocation for a relayed JOIN.
|
||||
AllocateRelay {
|
||||
player_id: Uuid,
|
||||
@@ -172,6 +208,22 @@ pub struct CharacterEntry {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "op", rename_all = "snake_case")]
|
||||
pub enum Response {
|
||||
SessionPending {
|
||||
kind: SessionMessageKind,
|
||||
total_size: usize,
|
||||
content_hash: String,
|
||||
},
|
||||
SessionIdle {
|
||||
closed: bool,
|
||||
},
|
||||
SessionData {
|
||||
offset: usize,
|
||||
data: String,
|
||||
},
|
||||
SessionAcked {
|
||||
received_size: usize,
|
||||
complete: bool,
|
||||
},
|
||||
Welcome {
|
||||
realm_id: coa_registry_proto::RealmId,
|
||||
automatic: bool,
|
||||
@@ -247,7 +299,10 @@ pub fn decode_request(bytes: &[u8]) -> Result<Request> {
|
||||
.map_err(|_| ControlError::Invalid("a request is not valid".into()))?;
|
||||
if !matches!(
|
||||
&r,
|
||||
Request::TransferOffer { .. } | Request::TransferChunk { .. }
|
||||
Request::TransferOffer { .. }
|
||||
| Request::OwnedTransferOffer { .. }
|
||||
| Request::TransferChunk { .. }
|
||||
| Request::SessionAck { .. }
|
||||
) && bytes.len() > MAX_CONTROL_REQUEST_BYTES
|
||||
{
|
||||
return Err(ControlError::Limit(
|
||||
@@ -255,6 +310,34 @@ pub fn decode_request(bytes: &[u8]) -> Result<Request> {
|
||||
));
|
||||
}
|
||||
match &r {
|
||||
Request::SessionRead {
|
||||
message_hash,
|
||||
offset,
|
||||
..
|
||||
} => {
|
||||
if message_hash.len() != 64
|
||||
|| !message_hash.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| *offset > MAX_SESSION_BYTES
|
||||
{
|
||||
return invalid("invalid portable session read");
|
||||
}
|
||||
}
|
||||
Request::SessionAck {
|
||||
message_hash,
|
||||
offset,
|
||||
total_size,
|
||||
data,
|
||||
..
|
||||
} => {
|
||||
if message_hash.len() != 64
|
||||
|| !message_hash.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| *total_size > MAX_SESSION_BYTES
|
||||
|| *offset > *total_size
|
||||
|| data.len() > 48 * 1024
|
||||
{
|
||||
return invalid("invalid portable session acknowledgement chunk");
|
||||
}
|
||||
}
|
||||
Request::Provision {
|
||||
desired, password, ..
|
||||
} => {
|
||||
@@ -280,6 +363,13 @@ pub fn decode_request(bytes: &[u8]) -> Result<Request> {
|
||||
total_size,
|
||||
collections,
|
||||
..
|
||||
}
|
||||
| Request::OwnedTransferOffer {
|
||||
ruleset,
|
||||
content_hash,
|
||||
total_size,
|
||||
collections,
|
||||
..
|
||||
} => {
|
||||
if !matches!(ruleset.as_str(), "coa" | "wildcard" | "reborn") {
|
||||
return invalid("transfer ruleset must be coa, wildcard, or reborn");
|
||||
|
||||
@@ -112,6 +112,10 @@ pub struct ProbeResponse {
|
||||
pub all_reachable: bool,
|
||||
}
|
||||
|
||||
/// Coordinator authentication is independent of the encrypted application protocol.
|
||||
/// Changing portable messages must not invalidate deployed v1 host/player proofs.
|
||||
pub const COORDINATOR_PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
pub fn challenge_nonce() -> String {
|
||||
let mut n = [0u8; 32];
|
||||
n[..16].copy_from_slice(Uuid::new_v4().as_bytes());
|
||||
@@ -122,7 +126,7 @@ pub fn challenge_nonce() -> String {
|
||||
fn host_input(realm: &RealmId, nonce: &str, ts: i64) -> Vec<u8> {
|
||||
format!(
|
||||
"coa-coord-host-v1\n{}\n{realm}\n{nonce}\n{ts}",
|
||||
crate::CONTROL_PROTOCOL_VERSION
|
||||
COORDINATOR_PROTOCOL_VERSION
|
||||
)
|
||||
.into_bytes()
|
||||
}
|
||||
@@ -130,7 +134,7 @@ fn host_input(realm: &RealmId, nonce: &str, ts: i64) -> Vec<u8> {
|
||||
fn player_input(player: &Uuid, public_key: &str, realm: &RealmId, nonce: &str, ts: i64) -> Vec<u8> {
|
||||
format!(
|
||||
"coa-coord-player-v1\n{}\n{}\n{public_key}\n{realm}\n{nonce}\n{ts}",
|
||||
crate::CONTROL_PROTOCOL_VERSION,
|
||||
COORDINATOR_PROTOCOL_VERSION,
|
||||
player.hyphenated()
|
||||
)
|
||||
.into_bytes()
|
||||
@@ -277,6 +281,14 @@ pub fn to_text(frame: &Frame) -> String {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn deployed_coordinator_proofs_stay_v1_when_application_protocol_changes() {
|
||||
let realm = RealmId::new();
|
||||
let player = Uuid::now_v7();
|
||||
assert_eq!(host_input(&realm, "nonce", 42), format!("coa-coord-host-v1\n1\n{realm}\nnonce\n42").into_bytes());
|
||||
assert_eq!(player_input(&player, "public", &realm, "nonce", 42), format!("coa-coord-player-v1\n1\n{player}\npublic\n{realm}\nnonce\n42").into_bytes());
|
||||
}
|
||||
|
||||
fn key(n: u8) -> SigningKey {
|
||||
SigningKey::from_bytes(&[n; 32])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
use coa_core::{
|
||||
db::{Account, Db},
|
||||
driver::{self, Verb},
|
||||
ra::Ra,
|
||||
Result,
|
||||
};
|
||||
use std::path::PathBuf;
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(std::env::args().nth(1).expect("disposable fixture"));
|
||||
assert!(root
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("coa-update-recovery-fixture-"));
|
||||
assert!(root.join("recovery-e2e-started").is_file());
|
||||
let result = std::panic::catch_unwind(|| -> Result<()> {
|
||||
let started = driver::run(&root, Verb::StartAll)?;
|
||||
assert!(started.ok, "{}", started.output);
|
||||
assert!(root.join("Core/Crashes").is_dir());
|
||||
let db = Db::from_repack(&root, Account::Admin)?;
|
||||
println!("Account acceptance: server started, connecting to RA");
|
||||
let mut ra = Ra::connect(&root)?;
|
||||
println!("Account acceptance: RA authenticated");
|
||||
let name = "STABILITYACCT2";
|
||||
assert_eq!(
|
||||
db.query("SELECT COUNT(*) FROM acore_auth.account WHERE username='STABILITYACCT2';")?
|
||||
.trim(),
|
||||
"0"
|
||||
);
|
||||
println!("Account acceptance: sending create command");
|
||||
coa_core::accounts::create(&root, name, "PrivateTest773", true)?;
|
||||
println!("Account acceptance: created");
|
||||
assert_eq!(db.query("SELECT COUNT(*) FROM acore_auth.account WHERE username='STABILITYACCT2' AND LENGTH(salt)=32 AND LENGTH(verifier)=32;")?.trim(), "1");
|
||||
assert!(ra
|
||||
.create_account(name, "PrivateTest773")
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("already taken"));
|
||||
ra.set_account_password(name, "PrivateTest774")?;
|
||||
ra.set_account_access(name, 3)?;
|
||||
assert_eq!(db.query("SELECT gmlevel FROM acore_auth.account_access WHERE id=(SELECT id FROM acore_auth.account WHERE username='STABILITYACCT2');")?.trim(), "3");
|
||||
ra.delete_account(name)?;
|
||||
assert_eq!(
|
||||
db.query("SELECT COUNT(*) FROM acore_auth.account WHERE username='STABILITYACCT2';")?
|
||||
.trim(),
|
||||
"0"
|
||||
);
|
||||
lost_reply(&root, &db)?;
|
||||
println!("PASS: native RA account create, SRP salt/verifier, duplicate refusal, password change, administrator access and deletion");
|
||||
Ok(())
|
||||
});
|
||||
let stopped = driver::run(&root, Verb::StopAll)?;
|
||||
assert!(stopped.ok, "{}", stopped.output);
|
||||
result.map_err(|_| {
|
||||
coa_core::Error::Invalid("Native account acceptance failed; fixture stopped".into())
|
||||
})?
|
||||
}
|
||||
|
||||
fn lost_reply(root: &std::path::Path, db: &Db) -> Result<()> {
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
let path = root.join("Settings/repack.json");
|
||||
let original = std::fs::read(&path)?;
|
||||
struct Restore(std::path::PathBuf, Vec<u8>);
|
||||
impl Drop for Restore {
|
||||
fn drop(&mut self) {
|
||||
std::fs::write(&self.0, &self.1).expect("restore disposable fixture settings");
|
||||
}
|
||||
}
|
||||
let restore = Restore(path.clone(), original.clone());
|
||||
let actual = coa_core::layout::read_ports(root).ra;
|
||||
for (name, forward, ready) in [
|
||||
("STABLOSTREPLY", true, true),
|
||||
("STABNOTCREATED", false, true),
|
||||
("STABNOTREADY", false, false),
|
||||
] {
|
||||
assert_eq!(
|
||||
db.query(&format!(
|
||||
"SELECT COUNT(*) FROM acore_auth.account WHERE username='{name}';"
|
||||
))?
|
||||
.trim(),
|
||||
"0"
|
||||
);
|
||||
let listener = TcpListener::bind(("127.0.0.1", 0))?;
|
||||
let mut config: serde_json::Value = serde_json::from_slice(&original)?;
|
||||
config["raPort"] = listener.local_addr()?.port().into();
|
||||
coa_core::fsx::atomic_write_json(&path, &config)?;
|
||||
let proxy = std::thread::spawn(move || {
|
||||
fn until(s: &mut TcpStream, marker: &[u8]) -> Vec<u8> {
|
||||
s.set_read_timeout(Some(std::time::Duration::from_secs(30)))
|
||||
.unwrap();
|
||||
let mut bytes = Vec::new();
|
||||
let mut byte = [0];
|
||||
while !bytes.ends_with(marker) {
|
||||
s.read_exact(&mut byte).unwrap();
|
||||
bytes.push(byte[0]);
|
||||
}
|
||||
bytes
|
||||
}
|
||||
let (mut client, _) = listener.accept().unwrap();
|
||||
let mut server = TcpStream::connect(("127.0.0.1", actual)).unwrap();
|
||||
for marker in [b"Username: ".as_slice(), b"Password: ".as_slice()] {
|
||||
client.write_all(&until(&mut server, marker)).unwrap();
|
||||
server.write_all(&until(&mut client, b"\r\n")).unwrap();
|
||||
}
|
||||
client.write_all(&until(&mut server, b"AC>")).unwrap();
|
||||
let readiness = until(&mut client, b"\r\n");
|
||||
assert_eq!(readiness, b"server info\r\n");
|
||||
if !ready {
|
||||
return;
|
||||
}
|
||||
server.write_all(&readiness).unwrap();
|
||||
client.write_all(&until(&mut server, b"AC>")).unwrap();
|
||||
let command = until(&mut client, b"\r\n");
|
||||
if forward {
|
||||
server.write_all(&command).unwrap();
|
||||
let _ = until(&mut server, b"AC>");
|
||||
}
|
||||
});
|
||||
let outcome = coa_core::accounts::create(root, name, "PrivateTest773", false);
|
||||
proxy.join().unwrap();
|
||||
std::fs::write(&path, &original)?;
|
||||
if forward {
|
||||
outcome?;
|
||||
assert_eq!(
|
||||
db.query(&format!(
|
||||
"SELECT COUNT(*) FROM acore_auth.account WHERE username='{name}';"
|
||||
))?
|
||||
.trim(),
|
||||
"1"
|
||||
);
|
||||
assert!(coa_core::accounts::create(root, name, "DifferentPass77", false).is_err());
|
||||
Ra::connect(root)?.delete_account(name)?;
|
||||
} else {
|
||||
assert!(outcome.is_err());
|
||||
if !ready {
|
||||
assert!(outcome
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("No create command was sent"));
|
||||
}
|
||||
assert_eq!(
|
||||
db.query(&format!(
|
||||
"SELECT COUNT(*) FROM acore_auth.account WHERE username='{name}';"
|
||||
))?
|
||||
.trim(),
|
||||
"0"
|
||||
);
|
||||
}
|
||||
}
|
||||
drop(restore);
|
||||
println!("PASS: a lost native console reply is reconciled by exact SRP credentials; a command that was never executed is rejected");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
use coa_core::{crashes, Result};
|
||||
fn main()->Result<()> {
|
||||
let root=std::path::PathBuf::from(std::env::args().nth(1).expect("marked native fixture"));
|
||||
assert!(root.join("recovery-e2e-started").is_file());
|
||||
let reports=crashes::list(&root,30)?;
|
||||
let matching:Vec<_>=reports.iter().filter(|r|r.full_log.contains("AllowableRaces")).collect();
|
||||
assert!(!matching.is_empty());
|
||||
assert!(matching.iter().all(|r|r.exception_code.as_deref()==Some("C0000420")));
|
||||
println!("PASS: Manager discovered {} actual worldserver SQL assertion reports from Core/Crashes, including Windows ANSI text",matching.len());
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use coa_core::{
|
||||
db::{Account, Db},
|
||||
driver::{self, Verb},
|
||||
ra::Ra,
|
||||
Result,
|
||||
};
|
||||
use std::{
|
||||
path::PathBuf,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(
|
||||
std::env::args()
|
||||
.nth(1)
|
||||
.expect("disposable recovery fixture"),
|
||||
);
|
||||
assert!(root
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("coa-update-recovery-fixture-"));
|
||||
assert!(root.join("recovery-e2e-started").is_file());
|
||||
let result = std::panic::catch_unwind(|| -> Result<()> {
|
||||
let start = driver::run(&root, Verb::StartAll)?;
|
||||
assert!(start.ok, "{}", start.output);
|
||||
let db = Db::from_repack(&root, Account::Admin)?;
|
||||
coa_core::companions::ensure_templates(&db)?;
|
||||
let mut ra = Ra::connect(&root)?;
|
||||
let before = coa_core::population::query(&root)?;
|
||||
assert_eq!(before.bots_online, 0);
|
||||
let rejected = db.query("SELECT name, at_login FROM acore_characters.characters WHERE guid=3030 AND account IN (SELECT id FROM acore_auth.account WHERE username LIKE 'COABOTHOST%');")?;
|
||||
assert!(rejected.contains("Christian Shadowheart"));
|
||||
assert_eq!(rejected.split_whitespace().last(), Some("1"));
|
||||
println!("Repair reply: {}", ra.run("botcmd spawnbot 3030")?);
|
||||
let repair_deadline = Instant::now() + Duration::from_secs(60);
|
||||
loop {
|
||||
let row = db.query(
|
||||
"SELECT name, at_login, online FROM acore_characters.characters WHERE guid=3030;",
|
||||
)?;
|
||||
if row.trim().ends_with("0\t1") {
|
||||
assert!(!row.contains("Christian Shadowheart"));
|
||||
println!("PASS: the exact previously rejected companion was renamed by server validation and logged in");
|
||||
break;
|
||||
}
|
||||
assert!(
|
||||
Instant::now() < repair_deadline,
|
||||
"previously rejected companion logged in"
|
||||
);
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
let _ = ra.despawn_all()?;
|
||||
let clear_deadline = Instant::now() + Duration::from_secs(30);
|
||||
while coa_core::population::query(&root)?.bots_online > 0 {
|
||||
assert!(Instant::now() < clear_deadline);
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
println!("Spawn reply: {}", ra.spawn_bots(21)?);
|
||||
let deadline = Instant::now() + Duration::from_secs(180);
|
||||
loop {
|
||||
let pop = coa_core::population::query(&root)?;
|
||||
if pop.bots_online >= 21 {
|
||||
println!(
|
||||
"PASS: {} companions logged in; real players={}",
|
||||
pop.bots_online, pop.players_online
|
||||
);
|
||||
break;
|
||||
}
|
||||
if Instant::now() > deadline {
|
||||
return Err(coa_core::Error::Invalid(format!(
|
||||
"Only {} of 21 companion logins completed",
|
||||
pop.bots_online
|
||||
)));
|
||||
}
|
||||
std::thread::sleep(Duration::from_secs(2));
|
||||
}
|
||||
let _ = ra.cancel_spawning()?;
|
||||
let _ = ra.despawn_all()?;
|
||||
let deadline = Instant::now() + Duration::from_secs(30);
|
||||
while coa_core::population::query(&root)?.bots_online > 0 {
|
||||
assert!(Instant::now() < deadline, "companions despawned");
|
||||
std::thread::sleep(Duration::from_secs(1));
|
||||
}
|
||||
println!("PASS: companion despawn completed");
|
||||
Ok(())
|
||||
});
|
||||
let stopped = driver::run(&root, Verb::StopAll)?;
|
||||
assert!(stopped.ok, "{}", stopped.output);
|
||||
result.map_err(|_| {
|
||||
coa_core::Error::Invalid("Companion native acceptance panicked; fixture stopped".into())
|
||||
})?
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use coa_core::{crashes, Result};
|
||||
fn main() -> Result<()> {
|
||||
let root = std::path::PathBuf::from(std::env::args().nth(1).expect("native crash evidence"));
|
||||
assert!(root.join(".crash-report-evidence").is_file());
|
||||
let reports = crashes::list(&root, 10)?;
|
||||
assert_eq!(reports.len(), 1);
|
||||
assert_eq!(reports[0].exception_code.as_deref(), Some("C0000005"));
|
||||
assert_eq!(reports[0].category, "access_violation");
|
||||
assert!(!reports[0].full_log.is_empty());
|
||||
println!("PASS: Manager crash history discovers and classifies the actual native handler report from Core/Crashes");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
use coa_core::{driver, fsx, process, Result};
|
||||
#[cfg(windows)]
|
||||
use std::os::windows::process::CommandExt;
|
||||
use std::{
|
||||
path::Path,
|
||||
process::{Child, Command, Stdio},
|
||||
};
|
||||
|
||||
struct OwnedChild(Child);
|
||||
impl Drop for OwnedChild {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
fn copy_tree(source: &Path, target: &Path) -> Result<()> {
|
||||
std::fs::create_dir_all(target)?;
|
||||
for entry in std::fs::read_dir(source)? {
|
||||
let entry = entry?;
|
||||
let destination = target.join(entry.file_name());
|
||||
if entry.file_type()?.is_dir() {
|
||||
copy_tree(&entry.path(), &destination)?;
|
||||
} else {
|
||||
std::fs::copy(entry.path(), destination)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
fn spawn(path: &Path) -> OwnedChild {
|
||||
let mut cmd = Command::new(path);
|
||||
cmd.args(["-t", "127.0.0.1"])
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null());
|
||||
#[cfg(windows)]
|
||||
cmd.creation_flags(0x0800_0000);
|
||||
OwnedChild(cmd.spawn().unwrap())
|
||||
}
|
||||
fn main() -> Result<()> {
|
||||
assert!(cfg!(windows));
|
||||
let source = std::path::PathBuf::from(
|
||||
std::env::args()
|
||||
.nth(1)
|
||||
.expect("marked disposable runtime source"),
|
||||
);
|
||||
assert_eq!(
|
||||
std::fs::read(source.join(".release-schema-fixture"))?,
|
||||
b"disposable release-schema fixture"
|
||||
);
|
||||
let temp = tempfile::tempdir()?;
|
||||
let root = temp.path();
|
||||
copy_tree(&source.join("Runtime"), &root.join("Runtime"))?;
|
||||
std::fs::create_dir_all(root.join("Core"))?;
|
||||
std::fs::create_dir_all(root.join("Scripts"))?;
|
||||
let system =
|
||||
std::path::PathBuf::from(std::env::var_os("SystemRoot").unwrap()).join("System32/ping.exe");
|
||||
std::fs::copy(&system, root.join("Core/worldserver.exe"))?;
|
||||
std::fs::copy(&system, root.join("foreign.exe"))?;
|
||||
let mut ports = Vec::new();
|
||||
let mut listeners = Vec::new();
|
||||
for _ in 0..4 {
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0")?;
|
||||
ports.push(listener.local_addr()?.port());
|
||||
listeners.push(listener);
|
||||
}
|
||||
let config = serde_json::json!({"mysqlPort":ports[0],"authPort":ports[1],"worldPort":ports[2],"raPort":ports[3],"raUsername":"fixture","raPassword":"fixture"});
|
||||
fsx::atomic_write_json(&root.join("Settings/repack.json"), &config)?;
|
||||
drop(listeners);
|
||||
let mut launcher = std::fs::read_to_string(source.join("Scripts/manage.py"))?;
|
||||
launcher.push_str("\nfrom contextlib import nullcontext\ndef control_lock(): return nullcontext()\ndef stop_all(config):\n assert not process('world'), 'world must stop before database'\n (ROOT/'database-stage').write_text('safe')\n");
|
||||
fsx::atomic_write(&root.join("Scripts/manage.py"), launcher.as_bytes())?;
|
||||
let mut foreign = spawn(&root.join("foreign.exe"));
|
||||
let foreign_identity = process::identity(foreign.0.id()).unwrap();
|
||||
fsx::atomic_write_json(&root.join(".state/world.json"), &foreign_identity)?;
|
||||
let rejected = driver::force_stop(root)?;
|
||||
assert!(!rejected.ok, "a foreign world record must be rejected");
|
||||
assert!(!root.join("database-stage").exists());
|
||||
assert!(foreign.0.try_wait()?.is_none());
|
||||
let mut owned = spawn(&root.join("Core/worldserver.exe"));
|
||||
let stopped = driver::force_stop(root)?;
|
||||
assert!(stopped.ok, "{}", stopped.output);
|
||||
assert!(owned.0.try_wait()?.is_some());
|
||||
assert!(
|
||||
foreign.0.try_wait()?.is_none(),
|
||||
"foreign process was untouched"
|
||||
);
|
||||
assert_eq!(std::fs::read(root.join("database-stage"))?, b"safe");
|
||||
println!("PASS: native Windows forced-stop rejects foreign records, recovers the exact owned world identity, stops it before the database stage, and leaves the foreign process alive");
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use coa_core::{
|
||||
backup,
|
||||
db::Db,
|
||||
driver::{self, Verb},
|
||||
fsx,
|
||||
manifest::{Kind, Migration},
|
||||
package::{self, BuildOptions},
|
||||
pkgsource::Source,
|
||||
registry::MetaDir,
|
||||
update::{self, RepackEnv},
|
||||
Result,
|
||||
};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(
|
||||
std::env::args()
|
||||
.nth(1)
|
||||
.expect("qualified disposable fixture"),
|
||||
);
|
||||
let sql = std::fs::read(std::env::args().nth(2).expect("corrective SQL"))?;
|
||||
assert_eq!(
|
||||
root.file_name().unwrap(),
|
||||
"coa-schema-fixture-stability-upgrade-76-85-20261030"
|
||||
);
|
||||
assert!(root.join(".release-schema-fixture").is_file());
|
||||
assert!(!root.join("legacy-start-data-e2e-started").exists());
|
||||
fsx::atomic_write(
|
||||
&root.join("legacy-start-data-e2e-started"),
|
||||
b"disposable regression test",
|
||||
)?;
|
||||
let result = std::panic::catch_unwind(|| -> Result<()> {
|
||||
let meta = coa_core::registry::metadata_dir_for(&root)?;
|
||||
let mut identities = String::new();
|
||||
let mut custom = String::new();
|
||||
backup::with_database(&root, |db| {
|
||||
assert_eq!(
|
||||
db.query(
|
||||
"SELECT COUNT(*) FROM acore_world.playercreateinfo WHERE race IN (33,45);"
|
||||
)?
|
||||
.trim(),
|
||||
"0"
|
||||
);
|
||||
let base: u32 = db
|
||||
.query("SELECT MAX(guid)+100 FROM acore_characters.characters;")?
|
||||
.trim()
|
||||
.parse()
|
||||
.unwrap();
|
||||
clone_character(db, base, 33, 12, "Thinprobe")?;
|
||||
clone_character(db, base + 1, 45, 30, "Leperprobe")?;
|
||||
identities = db.query(&format!(
|
||||
"SELECT * FROM acore_characters.characters WHERE guid IN ({base},{});",
|
||||
base + 1
|
||||
))?;
|
||||
let missing = coa_core::character_start_check::missing(db)?;
|
||||
assert!(missing
|
||||
.iter()
|
||||
.any(|c| c.guid == base && c.race == 33 && c.class == 12));
|
||||
assert!(missing
|
||||
.iter()
|
||||
.any(|c| c.guid == base + 1 && c.race == 45 && c.class == 30));
|
||||
db.query("INSERT INTO acore_world.playercreateinfo SELECT 33,class,map,zone,position_x+0.125,position_y,position_z,orientation FROM acore_world.playercreateinfo WHERE race=1 AND class=32;")?;
|
||||
custom =
|
||||
db.query("SELECT * FROM acore_world.playercreateinfo WHERE race=33 AND class=32;")?;
|
||||
fsx::atomic_write(
|
||||
&root.join("legacy-probe-guids.json"),
|
||||
serde_json::to_string(&[base, base + 1])?.as_bytes(),
|
||||
)?;
|
||||
println!("PASS: reproduced missing starting data for existing race 33/class 12 and race 45/class 30 characters");
|
||||
Ok(())
|
||||
})?;
|
||||
let source = root.join("legacy-start-data-source");
|
||||
let pkg = root.join("legacy-start-data-package");
|
||||
fsx::atomic_write(
|
||||
&source.join("Core/legacy-start-data-proof.txt"),
|
||||
b"corrective migration acceptance",
|
||||
)?;
|
||||
fsx::atomic_write(
|
||||
&source.join("_migrations/world/stabilization_legacy_starts.sql"),
|
||||
&sql,
|
||||
)?;
|
||||
let manifest = package::build(
|
||||
&source,
|
||||
&pkg,
|
||||
&BuildOptions {
|
||||
kind: Kind::Update,
|
||||
version: "2.0.0".into(),
|
||||
core_commit: None,
|
||||
bots_commit: None,
|
||||
built_at: "private regression acceptance".into(),
|
||||
part_size: 1 << 20,
|
||||
migrations: vec![Migration {
|
||||
id: "stabilization_legacy_starts".into(),
|
||||
db: "world".into(),
|
||||
sha256: fsx::sha256_bytes(&sql),
|
||||
compatible_sha256: vec![],
|
||||
destructive: false,
|
||||
}],
|
||||
},
|
||||
&|_| {},
|
||||
)?;
|
||||
let key = SigningKey::generate(&mut rand_core::OsRng);
|
||||
fsx::atomic_write(
|
||||
&pkg.join("manifest.json.sig"),
|
||||
STANDARD
|
||||
.encode(
|
||||
key.sign(&std::fs::read(pkg.join("manifest.json"))?)
|
||||
.to_bytes(),
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
let trusted = STANDARD.encode(key.verifying_key().to_bytes());
|
||||
let env = RepackEnv {
|
||||
root: &root,
|
||||
meta_dir: &meta,
|
||||
};
|
||||
let out = update::apply(
|
||||
&update::Params {
|
||||
root: &root,
|
||||
meta_dir: &meta,
|
||||
source: Source::Dir(pkg),
|
||||
trusted_key: &trusted,
|
||||
cancel: Default::default(),
|
||||
resolutions: BTreeMap::new(),
|
||||
env: &env,
|
||||
fail_after_ops: None,
|
||||
},
|
||||
&|step, pct| println!("{pct}% {step}"),
|
||||
)?;
|
||||
assert_eq!(out.txn.state, update::State::Committed);
|
||||
assert_eq!(
|
||||
MetaDir::open(&meta)?.1.core.version.as_deref(),
|
||||
Some("2.0.0")
|
||||
);
|
||||
backup::with_database(&root, |db| {
|
||||
let guids: Vec<u32> =
|
||||
serde_json::from_slice(&std::fs::read(root.join("legacy-probe-guids.json"))?)?;
|
||||
assert_eq!(
|
||||
db.query(&format!(
|
||||
"SELECT * FROM acore_characters.characters WHERE guid IN ({},{});",
|
||||
guids[0], guids[1]
|
||||
))?,
|
||||
identities
|
||||
);
|
||||
assert!(coa_core::character_start_check::missing(db)?
|
||||
.iter()
|
||||
.all(|c| !guids.contains(&c.guid)));
|
||||
assert_eq!(
|
||||
db.query("SELECT * FROM acore_world.playercreateinfo WHERE race=33 AND class=32;")?,
|
||||
custom
|
||||
);
|
||||
assert!(coa_core::migrations::status(db, &manifest.migrations)?
|
||||
.iter()
|
||||
.all(|m| m.status == coa_core::migrations::Status::Applied));
|
||||
let before = counts(db)?;
|
||||
db.query(&format!(
|
||||
"USE acore_world; {}",
|
||||
String::from_utf8_lossy(&sql)
|
||||
))?;
|
||||
assert_eq!(counts(db)?, before);
|
||||
assert_eq!(
|
||||
db.query("SELECT * FROM acore_world.playercreateinfo WHERE race=33 AND class=32;")?,
|
||||
custom
|
||||
);
|
||||
println!("PASS: signed Manager update restored legacy starts, preserved complete character rows and custom starts, and remained idempotent on a second SQL execution");
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(())
|
||||
});
|
||||
let stopped = driver::run(&root, Verb::StopAll)?;
|
||||
assert!(stopped.ok, "{}", stopped.output);
|
||||
result.map_err(|_| {
|
||||
coa_core::Error::Invalid("Legacy start regression failed; fixture stopped".into())
|
||||
})?
|
||||
}
|
||||
fn counts(db: &Db) -> Result<String> {
|
||||
db.query("SELECT COUNT(*) FROM acore_world.playercreateinfo WHERE race IN (33,45); SELECT COUNT(*) FROM acore_world.playercreateinfo_item WHERE race IN (33,45); SELECT COUNT(*) FROM acore_world.playercreateinfo_action WHERE race IN (33,45); SELECT COUNT(*) FROM acore_world.player_race_stats WHERE Race IN (33,45); SELECT COUNT(*) FROM acore_world.ascension_custom_class_race WHERE race IN (33,45);")
|
||||
}
|
||||
fn clone_character(db: &Db, guid: u32, race: u32, class: u32, name: &str) -> Result<()> {
|
||||
let columns = db.query("SHOW COLUMNS FROM acore_characters.characters;")?;
|
||||
let names: Vec<_> = columns
|
||||
.lines()
|
||||
.map(|l| l.split('\t').next().unwrap())
|
||||
.collect();
|
||||
let values: Vec<String> = names
|
||||
.iter()
|
||||
.map(|c| match *c {
|
||||
"guid" => guid.to_string(),
|
||||
"name" => format!("'{name}'"),
|
||||
"race" => race.to_string(),
|
||||
"class" => class.to_string(),
|
||||
"online" | "at_login" => "0".into(),
|
||||
_ => format!("`{c}`"),
|
||||
})
|
||||
.collect();
|
||||
db.query(&format!("INSERT INTO acore_characters.characters ({}) SELECT {} FROM acore_characters.characters WHERE class={class} LIMIT 1;",names.iter().map(|c|format!("`{c}`")).collect::<Vec<_>>().join(","),values.join(",")))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
use coa_core::{backup, schema_check, Result};
|
||||
fn main()->Result<()> {
|
||||
let root=std::path::PathBuf::from(std::env::args().nth(1).unwrap());
|
||||
let contract=std::path::PathBuf::from(std::env::args().nth(2).unwrap());
|
||||
assert!(root.join("recovery-e2e-started").is_file());
|
||||
assert!(contract.join(".release-schema-fixture").is_file());
|
||||
backup::with_database(&root,|db| {for p in schema_check::check(db,&contract)? {println!("{}.{}.{}: {}",p.database,p.table,p.column,p.detail);} Ok(())})
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use coa_core::{
|
||||
backup,
|
||||
driver::{self, Verb},
|
||||
fsx,
|
||||
manifest::{Kind, Migration},
|
||||
package::{self, BuildOptions},
|
||||
pkgsource::Source,
|
||||
update::{self, RepackEnv},
|
||||
Result,
|
||||
};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(std::env::args().nth(1).unwrap());
|
||||
let core = PathBuf::from(std::env::args().nth(2).unwrap());
|
||||
let release = PathBuf::from(std::env::args().nth(3).unwrap());
|
||||
assert!(root.join("recovery-e2e-started").is_file());
|
||||
let result = std::panic::catch_unwind(|| -> Result<()> {
|
||||
let meta = coa_core::registry::metadata_dir_for(&root)?;
|
||||
let source = root.join("schema-sync-source-v2");
|
||||
let pkg = root.join("schema-sync-package-v2");
|
||||
assert!(!pkg.exists());
|
||||
fsx::atomic_write(
|
||||
&source.join("Core/schema-sync-proof.txt"),
|
||||
b"official schema migration acceptance",
|
||||
)?;
|
||||
let published =
|
||||
coa_core::manifest::Manifest::parse(&std::fs::read(release.join("manifest.json"))?)?;
|
||||
let contract_path = PathBuf::from("C:/games/source/coa-schema-fixture-stability-upgrade-76-85-20261030/Scripts/database-schema.json");
|
||||
let contract = std::fs::read(contract_path)?;
|
||||
let expected = published
|
||||
.files
|
||||
.iter()
|
||||
.find(|f| f.path == "Scripts/database-schema.json")
|
||||
.unwrap();
|
||||
assert_eq!(fsx::sha256_bytes(&contract), expected.sha256);
|
||||
fsx::atomic_write(&source.join("Scripts/database-schema.json"), &contract)?;
|
||||
let mut migrations: Vec<Migration> = Vec::new();
|
||||
for id in [
|
||||
"rev_1793212891571528600",
|
||||
"rev_20261027_18_coa_strange_piece_of_scrap_faction_diamond",
|
||||
] {
|
||||
let m = published
|
||||
.migrations
|
||||
.iter()
|
||||
.find(|m| m.id == id)
|
||||
.unwrap()
|
||||
.clone();
|
||||
let sql = std::fs::read(
|
||||
core.join(format!("data/sql/updates/pending_db_{}/{}.sql", m.db, m.id)),
|
||||
)?;
|
||||
assert_eq!(fsx::sha256_bytes(&sql), m.sha256);
|
||||
fsx::atomic_write(
|
||||
&source.join(format!("_migrations/{}/{}.sql", m.db, m.id)),
|
||||
&sql,
|
||||
)?;
|
||||
migrations.push(m);
|
||||
}
|
||||
package::build(
|
||||
&source,
|
||||
&pkg,
|
||||
&BuildOptions {
|
||||
kind: Kind::Update,
|
||||
version: "3.0.0".into(),
|
||||
core_commit: None,
|
||||
bots_commit: None,
|
||||
built_at: "private native fixture schema sync".into(),
|
||||
part_size: 1 << 20,
|
||||
migrations,
|
||||
},
|
||||
&|_| {},
|
||||
)?;
|
||||
let key = SigningKey::generate(&mut rand_core::OsRng);
|
||||
fsx::atomic_write(
|
||||
&pkg.join("manifest.json.sig"),
|
||||
STANDARD
|
||||
.encode(
|
||||
key.sign(&std::fs::read(pkg.join("manifest.json"))?)
|
||||
.to_bytes(),
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
let trusted = STANDARD.encode(key.verifying_key().to_bytes());
|
||||
let env = RepackEnv {
|
||||
root: &root,
|
||||
meta_dir: &meta,
|
||||
};
|
||||
let out = update::apply(
|
||||
&update::Params {
|
||||
root: &root,
|
||||
meta_dir: &meta,
|
||||
source: Source::Dir(pkg),
|
||||
trusted_key: &trusted,
|
||||
cancel: Default::default(),
|
||||
resolutions: BTreeMap::from([(
|
||||
"Scripts/database-schema.json".into(),
|
||||
update::Resolution::Replace,
|
||||
)]),
|
||||
env: &env,
|
||||
fail_after_ops: None,
|
||||
},
|
||||
&|step, pct| println!("{pct}% {step}"),
|
||||
)?;
|
||||
assert_eq!(out.txn.state, update::State::Committed);
|
||||
backup::with_database(&root, |db| {
|
||||
assert_eq!(
|
||||
db.query("SELECT name,at_login FROM acore_characters.characters WHERE guid=3030;")?
|
||||
.trim(),
|
||||
"Christian Shadowheart\t1"
|
||||
);
|
||||
assert_eq!(
|
||||
db.query("SELECT id FROM acore_characters.manager_recovery_probe;")?
|
||||
.trim(),
|
||||
"42"
|
||||
);
|
||||
assert_eq!(
|
||||
db.query("SELECT id FROM acore_world.manager_recovery_probe;")?
|
||||
.trim(),
|
||||
"44"
|
||||
);
|
||||
Ok(())
|
||||
})?;
|
||||
println!("PASS: the exact two published migrations brought the old fixture schema forward through the Manager updater while preserving the rejected companion and recovery probes");
|
||||
Ok(())
|
||||
});
|
||||
let stop = driver::run(&root, Verb::StopAll)?;
|
||||
assert!(stop.ok, "{}", stop.output);
|
||||
result.map_err(|_| {
|
||||
coa_core::Error::Invalid("Schema sync acceptance failed; fixture stopped".into())
|
||||
})?
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use coa_core::{
|
||||
backup, driver, fsx,
|
||||
manifest::{Kind, Migration},
|
||||
package::{self, BuildOptions},
|
||||
pkgsource::Source,
|
||||
registry::MetaDir,
|
||||
update::{self, RepackEnv},
|
||||
Result,
|
||||
};
|
||||
use ed25519_dalek::{Signer, SigningKey};
|
||||
use std::{collections::BTreeMap, path::PathBuf};
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let root = PathBuf::from(
|
||||
std::env::args()
|
||||
.nth(1)
|
||||
.expect("disposable recovery fixture"),
|
||||
);
|
||||
assert!(root
|
||||
.file_name()
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("coa-update-recovery-fixture-"));
|
||||
assert!(root.join("recovery-e2e-started").is_file());
|
||||
let cycles_only = std::env::args().nth(2).as_deref() == Some("--cycles-only");
|
||||
if !cycles_only {
|
||||
assert!(!root.join("success-e2e-started").exists());
|
||||
fsx::atomic_write(
|
||||
&root.join("success-e2e-started"),
|
||||
b"disposable success acceptance",
|
||||
)?;
|
||||
}
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if cycles_only {
|
||||
restart_cycles(&root)
|
||||
} else {
|
||||
exercise(&root)
|
||||
}
|
||||
}));
|
||||
assert!(
|
||||
driver::run(&root, driver::Verb::StopAll)?.ok,
|
||||
"fixture shutdown"
|
||||
);
|
||||
result.map_err(|_| {
|
||||
coa_core::Error::Invalid(
|
||||
"Success acceptance assertion failed; fixture services stopped".into(),
|
||||
)
|
||||
})?
|
||||
}
|
||||
|
||||
fn exercise(root: &std::path::Path) -> Result<()> {
|
||||
let meta = coa_core::registry::metadata_dir_for(root)?;
|
||||
assert!(update::unfinished(&meta).is_none());
|
||||
assert!(driver::run(root, driver::Verb::StopAll)?.ok);
|
||||
let source = root.join("success-source");
|
||||
let pkg = root.join("success-package");
|
||||
fsx::atomic_write(
|
||||
&source.join("Core/stabilization-success.txt"),
|
||||
b"verified update",
|
||||
)?;
|
||||
let sql = b"UPDATE manager_recovery_probe SET id=44 WHERE id=43;";
|
||||
fsx::atomic_write(
|
||||
&source.join("_migrations/world/stabilization_success.sql"),
|
||||
sql,
|
||||
)?;
|
||||
let manifest = package::build(
|
||||
&source,
|
||||
&pkg,
|
||||
&BuildOptions {
|
||||
kind: Kind::Update,
|
||||
version: "2.0.0".into(),
|
||||
core_commit: None,
|
||||
bots_commit: None,
|
||||
built_at: "disposable acceptance".into(),
|
||||
part_size: 1 << 20,
|
||||
migrations: vec![Migration {
|
||||
id: "stabilization_success".into(),
|
||||
db: "world".into(),
|
||||
sha256: fsx::sha256_bytes(sql),
|
||||
compatible_sha256: vec![],
|
||||
destructive: false,
|
||||
}],
|
||||
},
|
||||
&|_| {},
|
||||
)?;
|
||||
let key = SigningKey::generate(&mut rand_core::OsRng);
|
||||
fsx::atomic_write(
|
||||
&pkg.join("manifest.json.sig"),
|
||||
STANDARD
|
||||
.encode(
|
||||
key.sign(&std::fs::read(pkg.join("manifest.json"))?)
|
||||
.to_bytes(),
|
||||
)
|
||||
.as_bytes(),
|
||||
)?;
|
||||
let trusted = STANDARD.encode(key.verifying_key().to_bytes());
|
||||
let env = RepackEnv {
|
||||
root,
|
||||
meta_dir: &meta,
|
||||
};
|
||||
let outcome = update::apply(
|
||||
&update::Params {
|
||||
root,
|
||||
meta_dir: &meta,
|
||||
source: Source::Dir(pkg),
|
||||
trusted_key: &trusted,
|
||||
cancel: Default::default(),
|
||||
resolutions: BTreeMap::new(),
|
||||
env: &env,
|
||||
fail_after_ops: None,
|
||||
},
|
||||
&|step, pct| println!("{pct}% {step}"),
|
||||
)?;
|
||||
assert_eq!(outcome.txn.state, update::State::Committed);
|
||||
assert_eq!(
|
||||
MetaDir::open(&meta)?.1.core.version.as_deref(),
|
||||
Some("2.0.0")
|
||||
);
|
||||
assert!(update::unfinished(&meta).is_none());
|
||||
assert_eq!(
|
||||
std::fs::read(root.join("Core/stabilization-success.txt"))?,
|
||||
b"verified update"
|
||||
);
|
||||
backup::with_database(root, |db| {
|
||||
assert_eq!(
|
||||
db.query("SELECT id FROM acore_characters.manager_recovery_probe;")?,
|
||||
"42"
|
||||
);
|
||||
assert_eq!(
|
||||
db.query("SELECT id FROM acore_characters.manager_recovery_view;")?,
|
||||
"42"
|
||||
);
|
||||
assert_eq!(
|
||||
db.query("SELECT id FROM acore_world.manager_recovery_probe;")?,
|
||||
"44"
|
||||
);
|
||||
assert!(coa_core::migrations::status(db, &manifest.migrations)?
|
||||
.iter()
|
||||
.all(|m| m.status == coa_core::migrations::Status::Applied));
|
||||
Ok(())
|
||||
})?;
|
||||
restart_cycles(root)?;
|
||||
println!("PASS: signed update committed after failed rehearsal and recovery; SQL, files, metadata and player data verified; two native restart cycles passed");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn restart_cycles(root: &std::path::Path) -> Result<()> {
|
||||
for cycle in 0..2 {
|
||||
let stopped = driver::run(root, driver::Verb::StopAll)?;
|
||||
assert!(
|
||||
stopped.ok,
|
||||
"Stop cycle {cycle}: {}",
|
||||
coa_core::diag::redact(&stopped.output)
|
||||
);
|
||||
let started = driver::run(root, driver::Verb::StartAll)?;
|
||||
assert!(started.ok, "{}", started.output);
|
||||
let pop = coa_core::population::query(root)?;
|
||||
println!(
|
||||
"Restart cycle {cycle}: world ready, players={}, bots={}",
|
||||
pop.players_online, pop.bots_online
|
||||
);
|
||||
}
|
||||
println!("PASS: two native restart cycles finished");
|
||||
Ok(())
|
||||
}
|
||||
@@ -30,15 +30,27 @@ fn main() -> Result<()> {
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.starts_with("coa-update-recovery-fixture-"));
|
||||
assert!(
|
||||
!root.join("recovery-e2e-started").exists(),
|
||||
"Use a new fixture; never reuse an owned server"
|
||||
);
|
||||
fsx::atomic_write(&root.join("recovery-e2e-started"), b"disposable fixture")?;
|
||||
let result = exercise(&root);
|
||||
let stopped = driver::run(&root, Verb::StopAll)?;
|
||||
assert!(stopped.ok);
|
||||
result
|
||||
let resume = std::env::args().nth(2).as_deref() == Some("--verify-recovered");
|
||||
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
|
||||
if resume {
|
||||
assert!(root.join("recovery-e2e-started").is_file());
|
||||
let manifest = coa_core::manifest::Manifest::parse(&std::fs::read(
|
||||
root.join("fixture-package/manifest.json"),
|
||||
)?)?;
|
||||
verify_recovered(&root, &manifest)
|
||||
} else {
|
||||
assert!(
|
||||
!root.join("recovery-e2e-started").exists(),
|
||||
"Use a new fixture"
|
||||
);
|
||||
fsx::atomic_write(&root.join("recovery-e2e-started"), b"disposable fixture")?;
|
||||
exercise(&root)
|
||||
}
|
||||
}));
|
||||
assert!(driver::run(&root, Verb::StopAll)?.ok);
|
||||
result.map_err(|_| {
|
||||
Error::Invalid("Recovery acceptance assertion failed; owned services were stopped".into())
|
||||
})?
|
||||
}
|
||||
|
||||
fn exercise(root: &Path) -> Result<()> {
|
||||
@@ -122,9 +134,18 @@ fn exercise(root: &Path) -> Result<()> {
|
||||
assert!(
|
||||
error
|
||||
.to_string()
|
||||
.contains("private update rehearsal failed"),
|
||||
.contains("files and databases were restored to the recovery point"),
|
||||
"{error}"
|
||||
);
|
||||
verify_recovered(root, &manifest)
|
||||
}
|
||||
|
||||
fn verify_recovered(root: &Path, manifest: &coa_core::manifest::Manifest) -> Result<()> {
|
||||
let meta = coa_core::registry::metadata_dir_for(root)?;
|
||||
let env = RepackEnv {
|
||||
root,
|
||||
meta_dir: &meta,
|
||||
};
|
||||
assert!(!root.join("Core/recovery-probe.txt").exists());
|
||||
assert!(update::unfinished(&meta).is_none());
|
||||
assert_eq!(
|
||||
@@ -168,6 +189,6 @@ fn exercise(root: &Path) -> Result<()> {
|
||||
Db::from_repack(root, Account::Admin)?.realm(),
|
||||
coa_core::realms::Mode::Coa
|
||||
);
|
||||
println!("PASS: private rehearsal caught failed SQL before changing the installed world, characters, ledger, objects, files or version; complete recovery succeeded");
|
||||
println!("PASS: failed SQL was rolled back; characters, world, ledger, views, triggers, routines, files and version were verified; the saved recovery point was restored again");
|
||||
Ok(())
|
||||
}
|
||||
@@ -80,6 +80,60 @@ fn upper(name: &str) -> Result<String> {
|
||||
Ok(name.to_ascii_uppercase())
|
||||
}
|
||||
|
||||
pub fn create(root: &Path, name: &str, password: &str, administrator: bool) -> Result<()> {
|
||||
validate_account(name, password)?;
|
||||
let name = name.to_ascii_uppercase();
|
||||
let db = Db::from_repack(root, Account::Admin)?;
|
||||
if db
|
||||
.query(&format!(
|
||||
"SELECT COUNT(*) FROM acore_auth.account WHERE username='{name}';"
|
||||
))?
|
||||
.trim()
|
||||
!= "0"
|
||||
{
|
||||
return Err(Error::Invalid("That account name is already taken.".into()));
|
||||
}
|
||||
let mut ra = Ra::connect(root)?;
|
||||
ra.performance().map_err(|error| Error::Invalid(format!("The server console is not ready for account creation. No create command was sent. {error}")))?;
|
||||
let confirmed = ra.create_account(&name, password);
|
||||
if let Err(error) = confirmed {
|
||||
let stored = db.query(&format!(
|
||||
"SELECT HEX(salt), HEX(verifier) FROM acore_auth.account WHERE username='{name}';"
|
||||
))?;
|
||||
if !matches_credentials(&stored, &name, password) {
|
||||
return Err(Error::Invalid(format!("Account creation was not confirmed. Refresh the account list before retrying. {error}")));
|
||||
}
|
||||
if administrator {
|
||||
ra = Ra::connect(root).map_err(|error| Error::Invalid(format!("The account was created, but administrator access was not confirmed. Refresh the account list and check its access level. {error}")))?;
|
||||
}
|
||||
}
|
||||
if administrator {
|
||||
if let Err(error) = ra.make_administrator(&name) {
|
||||
let access = db.query(&format!("SELECT COUNT(*) FROM acore_auth.account_access WHERE id=(SELECT id FROM acore_auth.account WHERE username='{name}') AND gmlevel=3 AND RealmID=-1;"))?;
|
||||
if access.trim() != "1" {
|
||||
return Err(Error::Invalid(format!("The account was created, but administrator access was not confirmed. Refresh the account list and check its access level. {error}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn matches_credentials(stored: &str, name: &str, password: &str) -> bool {
|
||||
let Some((salt, verifier)) = stored.trim().split_once('\t') else {
|
||||
return false;
|
||||
};
|
||||
let Ok(salt) = hex::decode(salt) else {
|
||||
return false;
|
||||
};
|
||||
let Ok(salt): std::result::Result<[u8; 32], _> = salt.try_into() else {
|
||||
return false;
|
||||
};
|
||||
let Ok(verifier) = hex::decode(verifier) else {
|
||||
return false;
|
||||
};
|
||||
verifier.as_slice() == crate::srp6::verifier(name, password, &salt)
|
||||
}
|
||||
|
||||
/// Give the account a new name. Needs the world console (the new password is what makes the login work again) and an
|
||||
/// account that is not logged in. The old name is put back if anything goes wrong.
|
||||
pub fn rename(root: &Path, ra: &mut Ra, old: &str, new: &str, password: &str) -> Result<()> {
|
||||
@@ -165,6 +219,19 @@ pub fn delete(root: &Path, ra: &mut Ra, name: &str) -> Result<()> {
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn lost_reply_requires_the_exact_requested_credentials() {
|
||||
let salt = [42; 32];
|
||||
let verifier = crate::srp6::verifier("NewPlayer", "Password77", &salt);
|
||||
let stored = format!("{}\t{}\n", hex::encode(salt), hex::encode(verifier));
|
||||
assert!(matches_credentials(&stored, "NEWPLAYER", "password77"));
|
||||
assert!(!matches_credentials(&stored, "NEWPLAYER", "wrongpass"));
|
||||
assert!(!matches_credentials(&stored, "OTHERPLAYER", "Password77"));
|
||||
for value in ["", "NULL\tNULL", "00\t00", "malformed"] {
|
||||
assert!(!matches_credentials(value, "NewPlayer", "Password77"));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rows_from_the_client_become_accounts() {
|
||||
let out = "2\tALICE\t3\t0\t2026-09-30 21:04:11\t4\n5\tBOB\t0\t1\t\t0\n7\tBROKEN\n";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
//! Every setting of the world server's configuration, for people who want more than the curated list: the documented
|
||||
//! settings come from `worldserver.conf.dist` (the file that ships with the server and explains each one), the current
|
||||
//! settings come from `worldserver.conf.dist` and `modules/coa.conf.dist`, the current
|
||||
//! values from the configuration the next start will use. Anything that holds a password, a port, an address, a folder or
|
||||
//! the database connection is left out; the Manager owns those.
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::Path;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::config::parser::ConfFile;
|
||||
use crate::config::{take_snapshot, targets, Scope};
|
||||
use crate::config::{take_snapshot, targets, Scope, Targets};
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fsx;
|
||||
|
||||
@@ -39,6 +39,14 @@ fn hidden(key: &str) -> bool {
|
||||
|| k.starts_with("logindatabase.")
|
||||
|| k.starts_with("worlddatabase.")
|
||||
|| k.starts_with("characterdatabase.")
|
||||
|| [
|
||||
"coa.classmodel",
|
||||
"coa.realmtype",
|
||||
"coa.gamemodemask",
|
||||
"coa.mapclass10towarrior",
|
||||
"coa.clientbooleanconfigs",
|
||||
]
|
||||
.contains(&k.as_str())
|
||||
|| [
|
||||
"bindip",
|
||||
"realmid",
|
||||
@@ -121,52 +129,85 @@ fn dist_path(root: &Path) -> std::path::PathBuf {
|
||||
root.join("Core/configs/worldserver.conf.dist")
|
||||
}
|
||||
|
||||
fn sources(root: &Path) -> Result<Vec<(std::path::PathBuf, Targets)>> {
|
||||
let mut result = vec![(dist_path(root), targets(root, Scope::Server)?)];
|
||||
let dist = root.join("Core/configs/modules/coa.conf.dist");
|
||||
if dist.is_file() {
|
||||
let active = root.join("Core/configs/modules/coa.conf");
|
||||
result.push((
|
||||
dist,
|
||||
Targets {
|
||||
read: active.clone(),
|
||||
writes: vec![active],
|
||||
generated: None,
|
||||
},
|
||||
));
|
||||
}
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
pub fn list(root: &Path) -> Result<Vec<Item>> {
|
||||
let dist = fs::read_to_string(dist_path(root)).map_err(|_| {
|
||||
Error::Invalid(
|
||||
"The documented settings file (worldserver.conf.dist) was not found on this server."
|
||||
.into(),
|
||||
)
|
||||
})?;
|
||||
let t = targets(root, Scope::Server)?;
|
||||
let active = ConfFile::parse_bytes(&fs::read(&t.read)?)?;
|
||||
Ok(parse_dist(&dist)
|
||||
.into_iter()
|
||||
.filter(|(k, _, _)| !hidden(k))
|
||||
.map(|(key, default, doc)| {
|
||||
let value = active
|
||||
.get(&key)
|
||||
.map(|v| v.trim().to_string())
|
||||
.unwrap_or_else(|| default.clone());
|
||||
let changed = value != default;
|
||||
Item {
|
||||
key,
|
||||
value,
|
||||
default,
|
||||
doc,
|
||||
changed,
|
||||
}
|
||||
})
|
||||
.collect())
|
||||
let mut items = Vec::new();
|
||||
for (dist_path, t) in sources(root)? {
|
||||
let dist = fs::read_to_string(&dist_path)?;
|
||||
let active = ConfFile::parse_bytes(&if t.read.exists() {
|
||||
fs::read(&t.read)?
|
||||
} else {
|
||||
dist.as_bytes().to_vec()
|
||||
})?;
|
||||
items.extend(
|
||||
parse_dist(&dist)
|
||||
.into_iter()
|
||||
.filter(|(k, _, _)| !hidden(k))
|
||||
.map(|(key, default, doc)| {
|
||||
let value = active
|
||||
.get(&key)
|
||||
.map(|v| v.trim().to_string())
|
||||
.unwrap_or_else(|| default.clone());
|
||||
let changed = value != default;
|
||||
Item {
|
||||
key,
|
||||
value,
|
||||
default,
|
||||
doc,
|
||||
changed,
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>(),
|
||||
);
|
||||
}
|
||||
Ok(items)
|
||||
}
|
||||
|
||||
/// Change settings that exist in the documented file. A value is one line of at most 500 characters. Returns the keys
|
||||
/// that actually changed; the previous files are snapshotted first (and can be restored from Settings).
|
||||
pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Result<Vec<String>> {
|
||||
let known: BTreeMap<String, String> =
|
||||
parse_dist(&fs::read_to_string(dist_path(root)).map_err(|_| {
|
||||
Error::Invalid("worldserver.conf.dist was not found on this server.".into())
|
||||
})?)
|
||||
.into_iter()
|
||||
.map(|(k, d, _)| (k, d))
|
||||
.collect();
|
||||
let t = targets(root, Scope::Server)?;
|
||||
let _lock = crate::update::operation_lock(meta)?;
|
||||
crate::update::ensure_recovered(meta)?;
|
||||
let sources = sources(root)?;
|
||||
let mut known = BTreeMap::new();
|
||||
let mut originals: Vec<(std::path::PathBuf, Vec<u8>)> = Vec::new();
|
||||
let mut confs: Vec<ConfFile> = Vec::new();
|
||||
for p in &t.writes {
|
||||
let bytes = fs::read(p)?;
|
||||
confs.push(ConfFile::parse_bytes(&bytes)?);
|
||||
originals.push((p.clone(), bytes));
|
||||
let mut owners = Vec::new();
|
||||
for (index, (dist, t)) in sources.iter().enumerate() {
|
||||
let defaults = fs::read(dist)?;
|
||||
for (key, default, _) in parse_dist(&String::from_utf8_lossy(&defaults)) {
|
||||
if known.insert(key.clone(), (default, index)).is_some() {
|
||||
return Err(Error::Invalid(format!(
|
||||
"{key} is documented in more than one configuration file."
|
||||
)));
|
||||
}
|
||||
}
|
||||
for p in &t.writes {
|
||||
let bytes = if p.exists() {
|
||||
fs::read(p)?
|
||||
} else {
|
||||
defaults.clone()
|
||||
};
|
||||
confs.push(ConfFile::parse_bytes(&bytes)?);
|
||||
originals.push((p.clone(), bytes));
|
||||
owners.push(index);
|
||||
}
|
||||
}
|
||||
let mut changed = Vec::new();
|
||||
for (key, value) in changes {
|
||||
@@ -181,10 +222,22 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Res
|
||||
"The value of {key} must be one line of text."
|
||||
)));
|
||||
}
|
||||
let same = confs.iter().all(|c| match c.get(key) {
|
||||
Some(v) => v.trim() == value,
|
||||
None => known[key] == value,
|
||||
});
|
||||
let (default, owner) = &known[key];
|
||||
if default.parse::<f64>().is_ok() && !value.parse::<f64>().is_ok_and(f64::is_finite)
|
||||
|| key == "CoA.LegacyMountFlight" && !matches!(value, "0" | "1")
|
||||
{
|
||||
return Err(Error::Invalid(format!(
|
||||
"The value of {key} must be a number matching its documented default."
|
||||
)));
|
||||
}
|
||||
let same = confs
|
||||
.iter()
|
||||
.zip(&owners)
|
||||
.filter(|(_, index)| *index == owner)
|
||||
.all(|(c, _)| match c.get(key) {
|
||||
Some(v) => v.trim() == value,
|
||||
None => default == value,
|
||||
});
|
||||
if !same {
|
||||
changed.push((key.clone(), value.to_string()));
|
||||
}
|
||||
@@ -192,20 +245,31 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Res
|
||||
if changed.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
for c in &mut confs {
|
||||
for (c, owner) in confs.iter_mut().zip(&owners) {
|
||||
for (k, v) in &changed {
|
||||
c.set(k, v, &["Added by CoA Server Manager"]);
|
||||
if known[k].1 == *owner {
|
||||
c.set(k, v, &["Added by CoA Server Manager"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
let texts: Vec<String> = confs.iter().map(ConfFile::to_text).collect();
|
||||
for text in &texts {
|
||||
for (text, owner) in texts.iter().zip(&owners) {
|
||||
let reread = ConfFile::parse(text);
|
||||
for (k, v) in &changed {
|
||||
if reread.get(k).map(str::trim) != Some(v.as_str()) {
|
||||
if known[k].1 == *owner && reread.get(k).map(str::trim) != Some(v.as_str()) {
|
||||
return Err(Error::Invalid(format!("internal check failed for {k}")));
|
||||
}
|
||||
}
|
||||
}
|
||||
let affected: std::collections::BTreeSet<_> =
|
||||
changed.iter().map(|(key, _)| known[key].1).collect();
|
||||
let (originals, texts): (Vec<_>, Vec<_>) = originals
|
||||
.into_iter()
|
||||
.zip(texts)
|
||||
.zip(owners)
|
||||
.filter(|(_, owner)| affected.contains(owner))
|
||||
.map(|(pair, _)| pair)
|
||||
.unzip();
|
||||
take_snapshot(
|
||||
meta,
|
||||
Scope::Server,
|
||||
@@ -232,6 +296,114 @@ pub fn save(root: &Path, meta: &Path, changes: &BTreeMap<String, String>) -> Res
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn unrelated_saves_do_not_create_coa_files_and_existing_realm_values_are_preserved() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let (root, meta) = server(d.path());
|
||||
let module = root.join("Core/configs/modules");
|
||||
fs::create_dir_all(&module).unwrap();
|
||||
fs::write(
|
||||
module.join("coa.conf.dist"),
|
||||
"CoA.LegacyMountFlight = 0\nCoA.ClassModel = \"coa\"\n",
|
||||
)
|
||||
.unwrap();
|
||||
save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("Rate.Drop.Item.Rare".into(), "1.5".into())]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(!module.join("coa.conf").exists());
|
||||
fs::write(
|
||||
module.join("coa.conf"),
|
||||
"# Reborn realm\nCoA.ClassModel = \"wcr\"\nCustom.Preserved = 42\n",
|
||||
)
|
||||
.unwrap();
|
||||
assert!(save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("CoA.ClassModel".into(), "\"coa\"".into())])
|
||||
)
|
||||
.is_err());
|
||||
save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("CoA.LegacyMountFlight".into(), "1".into())]),
|
||||
)
|
||||
.unwrap();
|
||||
let actual = fs::read_to_string(module.join("coa.conf")).unwrap();
|
||||
assert!(
|
||||
actual.contains("CoA.ClassModel = \"wcr\"") && actual.contains("Custom.Preserved = 42")
|
||||
);
|
||||
assert!(actual.contains("# Reborn realm"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn coa_settings_are_found_saved_to_the_module_and_snapshotted_without_profile_overrides() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
let (root, meta) = server(d.path());
|
||||
let module = root.join("Core/configs/modules");
|
||||
fs::create_dir_all(&module).unwrap();
|
||||
let defaults = "[worldserver]\n# Mount flight rules\nCoA.LegacyMountFlight = 0\nCoA.ClassModel = \"coa\"\nCoA.RealmType = \"live\"\n";
|
||||
fs::write(module.join("coa.conf.dist"), defaults).unwrap();
|
||||
let original_world = fs::read(root.join("Core/configs/worldserver.conf")).unwrap();
|
||||
let items = list(&root).unwrap();
|
||||
assert!(items
|
||||
.iter()
|
||||
.any(|i| i.key == "CoA.LegacyMountFlight" && i.value == "0"));
|
||||
assert!(!items
|
||||
.iter()
|
||||
.any(|i| i.key == "CoA.ClassModel" || i.key == "CoA.RealmType"));
|
||||
for value in ["2", "invalid", "1\nInjected = 1"] {
|
||||
assert!(save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([("CoA.LegacyMountFlight".into(), value.into())])
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
assert!(!module.join("coa.conf").exists());
|
||||
save(
|
||||
&root,
|
||||
&meta,
|
||||
&BTreeMap::from([
|
||||
("CoA.LegacyMountFlight".into(), "1".into()),
|
||||
("Rate.Drop.Item.Rare".into(), "1.5".into()),
|
||||
]),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(fs::read_to_string(module.join("coa.conf"))
|
||||
.unwrap()
|
||||
.contains("CoA.LegacyMountFlight = 1"));
|
||||
let world = fs::read_to_string(root.join("Core/configs/worldserver.conf")).unwrap();
|
||||
assert!(!world.contains("CoA.LegacyMountFlight"));
|
||||
assert!(world.contains("Rate.Drop.Item.Rare = 1.5"));
|
||||
assert!(!String::from_utf8(original_world)
|
||||
.unwrap()
|
||||
.contains("CoA.LegacyMountFlight"));
|
||||
assert_eq!(
|
||||
list(&root)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i.key == "CoA.LegacyMountFlight")
|
||||
.unwrap()
|
||||
.value,
|
||||
"1"
|
||||
);
|
||||
assert!(!crate::config::list_snapshots(&meta).is_empty());
|
||||
let saved = crate::config::list_snapshots(&meta).remove(0);
|
||||
crate::config::restore_snapshot(&meta, &saved.id).unwrap();
|
||||
assert_eq!(
|
||||
list(&root)
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|i| i.key == "CoA.LegacyMountFlight")
|
||||
.unwrap()
|
||||
.value,
|
||||
"0"
|
||||
);
|
||||
}
|
||||
|
||||
const DIST: &str = "[worldserver]\r\n\r\n###################\r\n# SECTION\r\n###################\r\n\r\n#\r\n# Rate.Drop.Item.Epic\r\n# Rate.Drop.Item.Rare\r\n# Description: Drop rates by quality.\r\n# Default: 1\r\n#\r\n\r\nRate.Drop.Item.Rare = 1\r\nRate.Drop.Item.Epic = 1\r\n\r\n#\r\n# LoginDatabaseInfo\r\n# Description: secret\r\n#\r\n\r\nLoginDatabaseInfo = \"127.0.0.1;3306;a;b;c\"\r\n\r\n#\r\n# Instance.ResetTimeHour\r\n# Description: Hour of the reset.\r\n# Important: Use 0-23.\r\n# Default: 4\r\n#\r\n\r\nInstance.ResetTimeHour = 4\r\n";
|
||||
|
||||
fn server(d: &Path) -> (std::path::PathBuf, std::path::PathBuf) {
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Update-only comparison of existing unsupported character starts. Never repairs or deletes rows.
|
||||
use crate::{db::Db, Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::path::Path;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
|
||||
pub struct MissingStart {
|
||||
pub guid: u32,
|
||||
pub race: u32,
|
||||
pub class: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Baseline {
|
||||
pub schema: u32,
|
||||
pub realms: BTreeMap<String, BTreeSet<MissingStart>>,
|
||||
}
|
||||
|
||||
pub fn parse_missing(text: &str) -> Result<BTreeSet<MissingStart>> {
|
||||
text.lines()
|
||||
.map(|line| {
|
||||
let values = line
|
||||
.split('\t')
|
||||
.map(str::parse::<u32>)
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map_err(|_| {
|
||||
Error::Invalid("Cannot read existing character start identities.".into())
|
||||
})?;
|
||||
if values.len() != 3 {
|
||||
return Err(Error::Invalid(
|
||||
"Incomplete character start identity.".into(),
|
||||
));
|
||||
}
|
||||
Ok(MissingStart {
|
||||
guid: values[0],
|
||||
race: values[1],
|
||||
class: values[2],
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn missing(db: &Db) -> Result<BTreeSet<MissingStart>> {
|
||||
parse_missing(&db.query("SELECT c.guid,c.race,c.class FROM acore_characters.characters c LEFT JOIN acore_world.playercreateinfo p ON p.race=c.race AND p.class=c.class WHERE p.race IS NULL;")?)
|
||||
}
|
||||
|
||||
pub fn capture(db: &Db, root: &Path) -> Result<Baseline> {
|
||||
let mut realms = BTreeMap::new();
|
||||
for mode in crate::realms::state(root)?.modes() {
|
||||
realms.insert(
|
||||
mode.name().to_owned(),
|
||||
missing(&db.clone().for_realm(mode))?,
|
||||
);
|
||||
}
|
||||
Ok(Baseline { schema: 1, realms })
|
||||
}
|
||||
|
||||
/// Match identity and race/class, never just totals: one new failure cannot replace an old one.
|
||||
pub fn introduced(
|
||||
current: &BTreeSet<MissingStart>,
|
||||
before: &BTreeSet<MissingStart>,
|
||||
) -> BTreeSet<MissingStart> {
|
||||
current.difference(before).copied().collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn old_gaps_are_not_new_character_loss() {
|
||||
let before = parse_missing("1\t44\t14\n2\t44\t14\n3\t44\t14").unwrap();
|
||||
assert!(introduced(&before, &before).is_empty());
|
||||
assert!(introduced(&BTreeSet::new(), &before).is_empty());
|
||||
let replaced = parse_missing("1\t44\t14\n2\t44\t14\n4\t44\t14").unwrap();
|
||||
assert_eq!(
|
||||
introduced(&replaced, &before),
|
||||
parse_missing("4\t44\t14").unwrap()
|
||||
);
|
||||
let changed = parse_missing("1\t44\t15").unwrap();
|
||||
assert_eq!(introduced(&changed, &before), changed);
|
||||
assert_eq!(introduced(&before, &BTreeSet::new()), before);
|
||||
}
|
||||
#[test]
|
||||
fn malformed_identity_is_not_silently_ignored() {
|
||||
for text in ["1\t44", "bad\t44\t14", "1\t44\t14\t0", "1\t44\t-1"] {
|
||||
assert!(parse_missing(text).is_err());
|
||||
}
|
||||
assert!(parse_missing("").unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reported_174_old_records_cannot_mask_one_newly_hidden_character() {
|
||||
// Reproduce the report's scale without shipping another person's diagnostics or identities.
|
||||
let before: BTreeSet<_> = (1..=174)
|
||||
.map(|guid| MissingStart {
|
||||
guid,
|
||||
race: 44,
|
||||
class: 14,
|
||||
})
|
||||
.collect();
|
||||
assert!(introduced(&before, &before).is_empty());
|
||||
let mut after = before.clone();
|
||||
after.remove(&MissingStart {
|
||||
guid: 1,
|
||||
race: 44,
|
||||
class: 14,
|
||||
});
|
||||
after.insert(MissingStart {
|
||||
guid: 175,
|
||||
race: 44,
|
||||
class: 14,
|
||||
});
|
||||
assert_eq!(after.len(), before.len());
|
||||
assert_eq!(
|
||||
introduced(&after, &before),
|
||||
BTreeSet::from([MissingStart {
|
||||
guid: 175,
|
||||
race: 44,
|
||||
class: 14
|
||||
}])
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
use crate::{fsx, Error, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{fs, path::Path};
|
||||
|
||||
#[derive(Default, Serialize, Deserialize)]
|
||||
pub struct Graphics {
|
||||
pub dxvk_disabled: bool,
|
||||
pub enhancements_disabled: bool,
|
||||
}
|
||||
|
||||
fn preferences(client: &Path) -> Result<Option<Graphics>> {
|
||||
match fs::read(client.join(".coa-manager/graphics.json")) {
|
||||
Ok(bytes) => Ok(Some(serde_json::from_slice(&bytes)?)),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
|
||||
Err(e) => Err(e.into()),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(client: &Path) -> Result<Graphics> {
|
||||
let saved = preferences(client)?.unwrap_or_default();
|
||||
let actual = |name: &str, remembered: bool| -> Result<bool> {
|
||||
let active = client.join(name).is_file();
|
||||
let inactive = client.join(format!("{name}.DISABLED")).is_file();
|
||||
if active && inactive {
|
||||
return Err(Error::Invalid(
|
||||
"Both enabled and disabled renderer files exist.".into(),
|
||||
));
|
||||
}
|
||||
Ok(inactive || (!active && remembered))
|
||||
};
|
||||
Ok(Graphics {
|
||||
dxvk_disabled: actual("d3d9.dll", saved.dxvk_disabled)?,
|
||||
enhancements_disabled: actual("dxgi.dll", saved.enhancements_disabled)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn disabled(client: &Path, path: &str) -> Result<bool> {
|
||||
let name = path.to_ascii_lowercase();
|
||||
if !matches!(name.as_str(), "d3d9.dll" | "dxgi.dll") {
|
||||
return Ok(false);
|
||||
}
|
||||
let state = status(client)?;
|
||||
Ok(if name == "d3d9.dll" {
|
||||
state.dxvk_disabled
|
||||
} else {
|
||||
state.enhancements_disabled
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set(client: &Path, name: &str, disabled: bool) -> Result<Graphics> {
|
||||
if !matches!(name, "d3d9.dll" | "dxgi.dll") {
|
||||
return Err(Error::Invalid("Unknown graphics setting.".into()));
|
||||
}
|
||||
if crate::client::is_running(client) {
|
||||
return Err(Error::Invalid(
|
||||
"Close the game before changing graphics settings.".into(),
|
||||
));
|
||||
}
|
||||
let active = fsx::safe_join(client, name)?;
|
||||
let inactive = fsx::safe_join(client, &format!("{name}.DISABLED"))?;
|
||||
if active.exists() && inactive.exists() {
|
||||
return Err(Error::Invalid("Both enabled and disabled renderer files exist. Resolve the conflict before changing graphics settings.".into()));
|
||||
}
|
||||
let mut state = status(client)?;
|
||||
if name == "d3d9.dll" {
|
||||
state.dxvk_disabled = disabled;
|
||||
} else {
|
||||
state.enhancements_disabled = disabled;
|
||||
}
|
||||
let (source, target) = if disabled {
|
||||
(&active, &inactive)
|
||||
} else {
|
||||
(&inactive, &active)
|
||||
};
|
||||
// The no-clobber link also preserves an existing destination if another app changes the setting.
|
||||
if source.is_file() {
|
||||
if let Err(error) = fs::hard_link(source, target) {
|
||||
if error.kind() == std::io::ErrorKind::AlreadyExists {
|
||||
return Err(error.into());
|
||||
}
|
||||
let mut output = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.open(target)?;
|
||||
let copied = std::fs::File::open(source)
|
||||
.and_then(|mut input| std::io::copy(&mut input, &mut output))
|
||||
.and_then(|_| output.sync_all());
|
||||
drop(output);
|
||||
if let Err(error) = copied {
|
||||
let _ = fs::remove_file(target);
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
if let Err(error) = fs::remove_file(source) {
|
||||
let _ = fs::remove_file(target);
|
||||
return Err(error.into());
|
||||
}
|
||||
}
|
||||
fsx::atomic_write_json(&client.join(".coa-manager/graphics.json"), &state)?;
|
||||
Ok(state)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
#[test]
|
||||
fn toggles_preserve_bytes_and_refuse_conflicts() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
fs::write(root.join("d3d9.dll"), b"custom renderer").unwrap();
|
||||
assert!(set(root, "d3d9.dll", true).unwrap().dxvk_disabled);
|
||||
assert!(!root.join("d3d9.dll").exists());
|
||||
assert_eq!(
|
||||
fs::read(root.join("d3d9.dll.DISABLED")).unwrap(),
|
||||
b"custom renderer"
|
||||
);
|
||||
assert!(!set(root, "d3d9.dll", false).unwrap().dxvk_disabled);
|
||||
fs::write(root.join("d3d9.dll.DISABLED"), b"other").unwrap();
|
||||
assert!(set(root, "d3d9.dll", false).is_err());
|
||||
assert_eq!(fs::read(root.join("d3d9.dll")).unwrap(), b"custom renderer");
|
||||
assert_eq!(fs::read(root.join("d3d9.dll.DISABLED")).unwrap(), b"other");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remembers_missing_wrapper_and_imports_launcher_marker() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path();
|
||||
assert!(set(root, "d3d9.dll", true).unwrap().dxvk_disabled);
|
||||
assert!(disabled(root, "d3d9.dll").unwrap());
|
||||
fs::write(root.join("dxgi.dll.DISABLED"), b"launcher disabled").unwrap();
|
||||
assert!(disabled(root, "dxgi.dll").unwrap());
|
||||
assert!(!disabled(root, "Data/d3d9.dll").unwrap());
|
||||
}
|
||||
}
|
||||
@@ -299,6 +299,11 @@ pub fn plan(
|
||||
bytes_per_sec: 0,
|
||||
file: Some(f.path.clone()),
|
||||
});
|
||||
if crate::client_graphics::disabled(client, &f.path)? {
|
||||
kept += 1;
|
||||
done += f.size;
|
||||
continue;
|
||||
}
|
||||
let path = fsx::safe_join(client, &f.path)?;
|
||||
let meta = match fs::metadata(&path) {
|
||||
Ok(meta) if meta.is_file() => meta,
|
||||
@@ -440,6 +445,9 @@ pub fn apply(a: &Apply, on_step: &dyn Fn(Step)) -> Result<()> {
|
||||
|
||||
let mut todo = Vec::new();
|
||||
for item in &a.plan.items {
|
||||
if crate::client_graphics::disabled(a.client, &item.path)? {
|
||||
continue;
|
||||
}
|
||||
if item.kind == Kind::Modified && a.keep_modified {
|
||||
let path = fsx::safe_join(a.client, &item.path)?;
|
||||
let meta = fs::metadata(&path)?;
|
||||
@@ -777,6 +785,52 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disabled_launcher_renderers_are_never_restored_even_with_a_stale_plan() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let files: Vec<(&str, &[u8])> = vec![("d3d9.dll", b"dxvk"), ("dxgi.dll", b"enhancements")];
|
||||
let manifest = manifest("v1", &files);
|
||||
let cancel = Cancel::default();
|
||||
let old_plan = plan(dir.path(), &manifest, &State::default(), &cancel, &|_| {}).unwrap();
|
||||
fs::write(dir.path().join("d3d9.dll.DISABLED"), b"custom disabled").unwrap();
|
||||
crate::client_graphics::set(dir.path(), "dxgi.dll", true).unwrap();
|
||||
let server = Server::new(&files);
|
||||
apply(
|
||||
&Apply {
|
||||
client: dir.path(),
|
||||
manifest: &manifest,
|
||||
plan: &old_plan,
|
||||
keep_modified: false,
|
||||
transport: &server,
|
||||
objects_url: "https://x.invalid/objects",
|
||||
cancel: &cancel,
|
||||
},
|
||||
&|_| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(server.requested.lock().unwrap().is_empty());
|
||||
assert!(!dir.path().join("d3d9.dll").exists());
|
||||
assert!(!dir.path().join("dxgi.dll").exists());
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("d3d9.dll.DISABLED")).unwrap(),
|
||||
b"custom disabled"
|
||||
);
|
||||
let after = plan(
|
||||
dir.path(),
|
||||
&manifest,
|
||||
&load_state(dir.path()).unwrap(),
|
||||
&cancel,
|
||||
&|_| {},
|
||||
)
|
||||
.unwrap();
|
||||
assert!(after.items.is_empty());
|
||||
crate::client_graphics::set(dir.path(), "d3d9.dll", false).unwrap();
|
||||
assert_eq!(
|
||||
fs::read(dir.path().join("d3d9.dll")).unwrap(),
|
||||
b"custom disabled"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renderer_files_configs_and_everything_not_in_the_manifest_survive_an_update_untouched() {
|
||||
let d = tempfile::tempdir().unwrap();
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
pub mod merge;
|
||||
pub mod parser;
|
||||
pub mod portable;
|
||||
pub mod schema;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
@@ -1033,6 +1034,53 @@ Spellbook.New = 5
|
||||
assert!(v.drift_keys.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn skill_controls_persist_and_reject_invalid_gains() {
|
||||
let (_d, root, meta) = fixture();
|
||||
let changes = BTreeMap::from([
|
||||
("SkillChance.Prospecting".into(), json!(true)),
|
||||
("SkillChance.Milling".into(), json!(true)),
|
||||
("SkillGain.Crafting".into(), json!(3)),
|
||||
("SkillGain.Defense".into(), json!(4)),
|
||||
("SkillGain.Gathering".into(), json!(5)),
|
||||
]);
|
||||
let report = save(&root, &meta, Scope::Server, &changes).unwrap();
|
||||
assert_eq!(report.changed.len(), 5);
|
||||
assert_eq!(report.restart, Some(Restart::World));
|
||||
for path in ["Settings/worldserver.conf.template", "Core/configs/worldserver.conf"] {
|
||||
let conf = load_conf(&root.join(path)).unwrap();
|
||||
for (key, expected) in [("SkillChance.Prospecting", "1"), ("SkillChance.Milling", "1"), ("SkillGain.Crafting", "3"), ("SkillGain.Defense", "4"), ("SkillGain.Gathering", "5")] {
|
||||
assert_eq!(conf.get(key), Some(expected));
|
||||
}
|
||||
assert_eq!(conf.get("Unknown.Custom"), Some("\"keep me\""));
|
||||
}
|
||||
for value in [json!(-1), json!(1.5), json!(451)] {
|
||||
assert!(save(&root, &meta, Scope::Server, &BTreeMap::from([("SkillGain.Crafting".into(), value)])).is_err());
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn creature_rates_save_independently_in_template_and_active_config() {
|
||||
let (_d, root, meta) = fixture();
|
||||
let mut changes = BTreeMap::new();
|
||||
for (i, group) in ["Normal", "Elite.Elite", "Elite.RARE", "Elite.RAREELITE", "Elite.WORLDBOSS"].iter().enumerate() {
|
||||
for (j, stat) in ["Damage", "SpellDamage", "HP"].iter().enumerate() {
|
||||
changes.insert(format!("Rate.Creature.{group}.{stat}"), json!(0.25 + (i * 3 + j) as f64 * 0.25));
|
||||
}
|
||||
}
|
||||
let report = save(&root, &meta, Scope::Server, &changes).unwrap();
|
||||
assert_eq!(report.changed.len(), 15);
|
||||
assert_eq!(report.restart, Some(Restart::World));
|
||||
for path in ["Settings/worldserver.conf.template", "Core/configs/worldserver.conf"] {
|
||||
let conf = load_conf(&root.join(path)).unwrap();
|
||||
for (key, value) in &changes {
|
||||
assert_eq!(conf.get(key).unwrap().parse::<f64>().unwrap(), value.as_f64().unwrap(), "{path}: {key}");
|
||||
}
|
||||
assert_eq!(conf.get("Unknown.Custom"), Some("\"keep me\""));
|
||||
}
|
||||
assert!(save(&root, &meta, Scope::Server, &changes).unwrap().changed.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_changes_only_that_key_in_template_and_generated_conf() {
|
||||
let (_d, root, meta) = fixture();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
//! Prepare the core side of the owner's portable-character opt-in.
|
||||
use std::{fs, path::Path};
|
||||
|
||||
use super::{parser::ConfFile, take_snapshot, targets, Scope};
|
||||
use crate::{fsx, Error, Result};
|
||||
|
||||
/// Configuration is changed only while the world is stopped. Disabling admission is
|
||||
/// handled by the control service and must not disable an active session's core.
|
||||
pub fn enable(root: &Path) -> Result<()> {
|
||||
let meta = crate::registry::metadata_dir_for(root)?;
|
||||
let _lock = crate::update::operation_lock_for(&meta, "configuring portable characters", false)?;
|
||||
let module = root.join("Core/configs/modules/coa.conf");
|
||||
let module_source = if module.is_file() {
|
||||
module.clone()
|
||||
} else {
|
||||
root.join("Core/configs/modules/coa.conf.dist")
|
||||
};
|
||||
let bytes = fs::read(&module_source).map_err(|_| Error::Invalid(
|
||||
"Update this server before enabling portable characters; its portable configuration is missing.".into()
|
||||
))?;
|
||||
let mut conf = ConfFile::parse_bytes(&bytes)?;
|
||||
if conf.get("PortableSession.Enable").is_none() {
|
||||
return Err(Error::Invalid("Update this server before enabling portable characters; its core configuration has no portable session support.".into()));
|
||||
}
|
||||
let mut writes = Vec::new();
|
||||
if conf.get("PortableSession.Enable").map(str::trim) != Some("1")
|
||||
|| conf.get("PortableImport.JobDir").map(str::trim) != Some("\"PortableImport\"")
|
||||
|| !module.is_file()
|
||||
{
|
||||
conf.set(
|
||||
"PortableSession.Enable",
|
||||
"1",
|
||||
&["Enabled by the realm owner's portable-character opt-in"],
|
||||
);
|
||||
conf.set(
|
||||
"PortableImport.JobDir",
|
||||
"\"PortableImport\"",
|
||||
&["Shared working directory for the core and Manager"],
|
||||
);
|
||||
writes.push((
|
||||
module.clone(),
|
||||
module.is_file().then(|| bytes.clone()),
|
||||
conf.to_text(),
|
||||
));
|
||||
}
|
||||
for path in targets(root, Scope::Server)?.writes {
|
||||
let bytes = fs::read(&path)?;
|
||||
let mut conf = ConfFile::parse_bytes(&bytes)?;
|
||||
if conf.get("CharacterDatabase.WorkerThreads").map(str::trim) != Some("1") {
|
||||
conf.set(
|
||||
"CharacterDatabase.WorkerThreads",
|
||||
"1",
|
||||
&["Portable sessions require ordered character database transactions"],
|
||||
);
|
||||
writes.push((path, Some(bytes), conf.to_text()));
|
||||
}
|
||||
}
|
||||
if writes.is_empty() {
|
||||
fs::create_dir_all(root.join("Core/PortableImport"))?;
|
||||
return Ok(());
|
||||
}
|
||||
let observed = crate::process::observe(root, &crate::layout::read_ports(root));
|
||||
if observed.world.state != crate::process::ServiceState::Stopped
|
||||
|| observed.world.conflict.is_some()
|
||||
|| std::net::TcpStream::connect_timeout(
|
||||
&std::net::SocketAddr::from(([127, 0, 0, 1], crate::layout::read_ports(root).world)),
|
||||
std::time::Duration::from_millis(100),
|
||||
)
|
||||
.is_ok()
|
||||
|| crate::multiworld::is_running(root)
|
||||
{
|
||||
return Err(Error::Invalid("Stop this server, then enable portable characters and save again. The Manager must configure the core before its next start.".into()));
|
||||
}
|
||||
let originals = writes
|
||||
.iter()
|
||||
.filter_map(|(p, old, _)| old.clone().map(|bytes| (p.clone(), bytes)))
|
||||
.collect::<Vec<_>>();
|
||||
take_snapshot(
|
||||
&meta,
|
||||
Scope::Server,
|
||||
"before enabling portable characters",
|
||||
&originals,
|
||||
)?;
|
||||
for (index, (path, _, text)) in writes.iter().enumerate() {
|
||||
if let Err(error) = fsx::atomic_write(path, text.as_bytes()) {
|
||||
for (path, old, _) in writes.iter().take(index) {
|
||||
if let Some(bytes) = old {
|
||||
let _ = fsx::atomic_write(path, bytes);
|
||||
} else {
|
||||
let _ = fs::remove_file(path);
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
fs::create_dir_all(root.join("Core/PortableImport"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn portable_opt_in_updates_the_effective_module_and_survives_launcher_rendering() {
|
||||
let temp = tempdir().unwrap();
|
||||
let root = temp.path().join("realm");
|
||||
fs::create_dir_all(root.join("Core/configs/modules")).unwrap();
|
||||
fs::create_dir_all(root.join("Settings")).unwrap();
|
||||
fs::write(
|
||||
root.join("Settings/repack.json"),
|
||||
r#"{"worldPort":49143,"authPort":49144,"mysqlPort":49145,"raPort":49146}"#,
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(
|
||||
root.join("Core/configs/modules/coa.conf"),
|
||||
"# Owner setting\nCoA.Enable = 1\nPortableSession.Enable = 0\n",
|
||||
)
|
||||
.unwrap();
|
||||
for name in [
|
||||
"Settings/worldserver.conf.template",
|
||||
"Core/configs/worldserver.conf",
|
||||
] {
|
||||
fs::write(
|
||||
root.join(name),
|
||||
"CharacterDatabase.WorkerThreads = 3\nMaxPlayerLevel = 80\n",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
enable(&root).unwrap();
|
||||
assert!(root.join("Core/PortableImport").is_dir());
|
||||
let module = fs::read_to_string(root.join("Core/configs/modules/coa.conf")).unwrap();
|
||||
assert!(
|
||||
module.contains("# Owner setting") && module.contains("PortableSession.Enable = 1")
|
||||
);
|
||||
for name in [
|
||||
"Settings/worldserver.conf.template",
|
||||
"Core/configs/worldserver.conf",
|
||||
] {
|
||||
let text = fs::read_to_string(root.join(name)).unwrap();
|
||||
assert!(
|
||||
text.contains("CharacterDatabase.WorkerThreads = 1")
|
||||
&& text.contains("MaxPlayerLevel = 80")
|
||||
);
|
||||
}
|
||||
let snapshots =
|
||||
super::super::list_snapshots(&crate::registry::metadata_dir_for(&root).unwrap());
|
||||
assert_eq!(snapshots.len(), 1);
|
||||
enable(&root).unwrap();
|
||||
assert_eq!(
|
||||
super::super::list_snapshots(&crate::registry::metadata_dir_for(&root).unwrap()).len(),
|
||||
1
|
||||
);
|
||||
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
fs::write(
|
||||
root.join("Settings/repack.json"),
|
||||
format!(
|
||||
r#"{{"worldPort":{}}}"#,
|
||||
listener.local_addr().unwrap().port()
|
||||
),
|
||||
)
|
||||
.unwrap();
|
||||
let disabled = "PortableSession.Enable = 0\n";
|
||||
fs::write(root.join("Core/configs/modules/coa.conf"), disabled).unwrap();
|
||||
assert!(enable(&root)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("Stop this server"));
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("Core/configs/modules/coa.conf")).unwrap(),
|
||||
disabled
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,21 @@ impl LocalBackend {
|
||||
}
|
||||
}
|
||||
|
||||
fn require_portable(&self, local_id: &str) -> Result<()> {
|
||||
let settings = settings::load(&self.settings_dir)?;
|
||||
if settings
|
||||
.realms
|
||||
.get(local_id)
|
||||
.is_some_and(|cfg| cfg.allow_portable)
|
||||
{
|
||||
Ok(())
|
||||
} else {
|
||||
Err(Error::Invalid(
|
||||
"The realm owner has disabled portable characters.".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
fn access(&self, local_id: &str) -> Result<RealmAccess> {
|
||||
let access =
|
||||
find_access(&self.descriptors, &(self.installs)(), local_id).ok_or_else(|| {
|
||||
@@ -107,6 +122,38 @@ impl LocalBackend {
|
||||
}
|
||||
|
||||
impl RealmBackend for LocalBackend {
|
||||
fn session_message(
|
||||
&self,
|
||||
local_id: &str,
|
||||
character: Uuid,
|
||||
session: Uuid,
|
||||
account: u32,
|
||||
) -> Result<super::service::RemoteSessionState> {
|
||||
let id = local_id.to_string();
|
||||
self.portable
|
||||
.call(move |service| service.remote_session_message(&id, character, session, account))
|
||||
.map_err(|error| unavailable("portable engine", error))?
|
||||
.map_err(|error| unavailable("portable session", error))
|
||||
}
|
||||
|
||||
fn session_ack(
|
||||
&self,
|
||||
local_id: &str,
|
||||
character: Uuid,
|
||||
session: Uuid,
|
||||
account: u32,
|
||||
kind: app::SessionMessageKind,
|
||||
bytes: &[u8],
|
||||
) -> Result<()> {
|
||||
let id = local_id.to_string();
|
||||
let bytes = bytes.to_vec();
|
||||
self.portable
|
||||
.call(move |service| {
|
||||
service.apply_remote_session_ack(&id, character, session, account, kind, &bytes)
|
||||
})
|
||||
.map_err(|error| unavailable("portable engine", error))?
|
||||
.map_err(|error| unavailable("portable session", error))
|
||||
}
|
||||
fn info(&self, local_id: &str) -> Result<RealmInfo> {
|
||||
let cfg = settings::load(&self.settings_dir)?.realms.remove(local_id);
|
||||
let access = self.access(local_id)?;
|
||||
@@ -256,6 +303,7 @@ impl RealmBackend for LocalBackend {
|
||||
}
|
||||
|
||||
fn export(&self, local_id: &str, _account_id: u32, guid: u32) -> Result<ClaimExport> {
|
||||
self.require_portable(local_id)?;
|
||||
let id = local_id.to_string();
|
||||
let bundle = self
|
||||
.portable
|
||||
@@ -277,6 +325,7 @@ impl RealmBackend for LocalBackend {
|
||||
character_id: Uuid,
|
||||
revision: u64,
|
||||
) -> Result<PreflightVerdict> {
|
||||
self.require_portable(local_id)?;
|
||||
let id = local_id.to_string();
|
||||
let cid = crate::portable::CharacterId::from_uuid(character_id)
|
||||
.map_err(|e| unavailable("character id", e))?;
|
||||
@@ -291,6 +340,7 @@ impl RealmBackend for LocalBackend {
|
||||
local_id: &str,
|
||||
params: RemoteImportParams,
|
||||
) -> Result<RemoteImportOutcome> {
|
||||
self.require_portable(local_id)?;
|
||||
let id = local_id.to_string();
|
||||
self.portable
|
||||
.call(move |s| s.import_remote_character(&id, params))
|
||||
@@ -298,6 +348,23 @@ impl RealmBackend for LocalBackend {
|
||||
.map_err(|e| unavailable("import remote", e))
|
||||
}
|
||||
|
||||
fn auth_port(&self, local_id: &str) -> Result<u16> {
|
||||
let access = self.access(local_id)?;
|
||||
let root = access
|
||||
.root
|
||||
.ok_or_else(|| Error::Invalid("The realm's auth port is unresolved.".into()))?;
|
||||
let settings: serde_json::Value =
|
||||
crate::fsx::read_json(&root.join("Settings/repack.json"))?;
|
||||
match settings.get("authPort") {
|
||||
None => Ok(coa_control_proto::relay::LOCAL_AUTH_PORT),
|
||||
Some(value) => value
|
||||
.as_u64()
|
||||
.and_then(|port| u16::try_from(port).ok())
|
||||
.filter(|port| *port != 0)
|
||||
.ok_or_else(|| Error::Invalid("The realm's auth port is invalid.".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn world_port(&self, local_id: &str) -> Result<u16> {
|
||||
if let Ok(override_port) = std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT") {
|
||||
if let Ok(p) = override_port.parse::<u16>() {
|
||||
@@ -331,6 +398,566 @@ mod tests {
|
||||
use std::fs;
|
||||
use tempfile::tempdir;
|
||||
|
||||
#[test]
|
||||
fn portable_access_requires_owner_opt_in_and_revokes_immediately() {
|
||||
let dir = tempdir().unwrap();
|
||||
let rt =
|
||||
PortableRuntime::start(&dir.path().join("portable"), Box::new(Vec::new), None).unwrap();
|
||||
let backend = LocalBackend::new(Arc::new(rt), dir.path(), dir.path(), Arc::new(Vec::new));
|
||||
assert!(backend.require_portable("srv-test").is_err());
|
||||
let mut cfg = settings::RegistrySettings::default();
|
||||
cfg.realms.insert(
|
||||
"srv-test".into(),
|
||||
settings::PublishConfig {
|
||||
realm_id: None,
|
||||
enabled: true,
|
||||
display_name: "Test".into(),
|
||||
description: String::new(),
|
||||
language: "en".into(),
|
||||
region: None,
|
||||
existing_only: false,
|
||||
allow_portable: true,
|
||||
route: None,
|
||||
},
|
||||
);
|
||||
settings::save(dir.path(), &cfg).unwrap();
|
||||
assert!(backend.require_portable("srv-test").is_ok());
|
||||
cfg.realms.get_mut("srv-test").unwrap().allow_portable = false;
|
||||
settings::save(dir.path(), &cfg).unwrap();
|
||||
assert!(backend
|
||||
.export("srv-test", 1, 1)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("disabled portable"));
|
||||
assert!(backend
|
||||
.check_transfer("srv-test", Uuid::new_v4(), 1)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("disabled portable"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore = "Requires disposable A/B databases, B worldserver configuration and REGISTRY_URL"]
|
||||
fn live_vps_transfer_uses_real_backend_and_preserves_the_working_copy() {
|
||||
use crate::control::{
|
||||
host_link::HostLink,
|
||||
join::{PlayerControl, RealmTarget},
|
||||
secrets::MemoryStore,
|
||||
service::HostService,
|
||||
store::ControlStore,
|
||||
transport::coordinator_url,
|
||||
};
|
||||
use crate::portable::{
|
||||
extension::ExtensionRegistry,
|
||||
realm::{
|
||||
live_import::{number, realms, reset_b},
|
||||
live_projection::fixture_character,
|
||||
profile::probe_capabilities,
|
||||
},
|
||||
session::live_session::{spec, Server},
|
||||
store::Store,
|
||||
};
|
||||
use crate::realm_registry::{advert::advertise, client::RegistryClient};
|
||||
use coa_registry_proto::{
|
||||
AccountProvisioning, Listing, Population, Rates, RealmId, RegisterRequest,
|
||||
REGISTRY_PROTOCOL_VERSION,
|
||||
};
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
let base = std::env::var("REGISTRY_URL").expect("REGISTRY_URL");
|
||||
let root = std::path::PathBuf::from(
|
||||
std::env::var("COA_REAL_CONTROL_ROOT").expect("disposable root"),
|
||||
);
|
||||
assert!(root.starts_with("C:/games/source/candidate-evidence-20261021"));
|
||||
crate::config::portable::enable(&root).expect("owner portable-character opt-in");
|
||||
let databases = realms().expect("disposable databases");
|
||||
reset_b(&databases.b);
|
||||
let server = Server::start(spec().expect("disposable worldserver"));
|
||||
struct Auth(std::process::Child);
|
||||
impl Drop for Auth {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let auth_port = crate::layout::read_ports(&root).auth;
|
||||
assert_eq!(
|
||||
auth_port, 57637,
|
||||
"Only the disposable auth port may be used"
|
||||
);
|
||||
assert!(std::net::TcpStream::connect(("127.0.0.1", auth_port)).is_err());
|
||||
let mut command = std::process::Command::new(root.join("Core/authserver.exe"));
|
||||
command
|
||||
.arg("-c")
|
||||
.arg(root.join("Core/configs/authserver.conf"))
|
||||
.current_dir(root.join("Core"))
|
||||
.stdin(std::process::Stdio::null())
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null());
|
||||
#[cfg(windows)]
|
||||
{
|
||||
use std::os::windows::process::CommandExt;
|
||||
command.creation_flags(0x0800_0000);
|
||||
}
|
||||
let mut auth = Auth(command.spawn().unwrap());
|
||||
let end = Instant::now() + Duration::from_secs(30);
|
||||
while std::net::TcpStream::connect(("127.0.0.1", auth_port)).is_err() {
|
||||
assert!(
|
||||
auth.0.try_wait().unwrap().is_none(),
|
||||
"Disposable authserver exited"
|
||||
);
|
||||
assert!(Instant::now() < end, "Disposable authserver did not listen");
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
let mut ra = crate::ra::Ra::connect_to(
|
||||
server.spec.ra_port,
|
||||
&server.spec.user,
|
||||
&server.spec.password,
|
||||
)
|
||||
.unwrap();
|
||||
let caps = probe_capabilities(
|
||||
&databases.b,
|
||||
Some(&root.join("Data")),
|
||||
Some(&mut ra),
|
||||
&ExtensionRegistry::new(),
|
||||
)
|
||||
.unwrap();
|
||||
let advertised = advertise(&caps).unwrap();
|
||||
let realm = RealmId::new();
|
||||
let mut seed = [0u8; 32];
|
||||
seed[..16].copy_from_slice(Uuid::new_v4().as_bytes());
|
||||
seed[16..].copy_from_slice(Uuid::new_v4().as_bytes());
|
||||
let key = ed25519_dalek::SigningKey::from_bytes(&seed);
|
||||
let clock = || {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64
|
||||
};
|
||||
let registry = RegistryClient::new(&base).unwrap();
|
||||
let listing = Listing {
|
||||
display_name: "Real portable candidate gate".into(),
|
||||
description: "Disposable database and real worldserver test".into(),
|
||||
language: "en".into(),
|
||||
region: None,
|
||||
rates: Rates::default(),
|
||||
modules: vec![],
|
||||
account_provisioning: AccountProvisioning {
|
||||
automatic: true,
|
||||
existing_only: false,
|
||||
},
|
||||
manager_version: crate::MANAGER_VERSION.into(),
|
||||
};
|
||||
registry
|
||||
.register(
|
||||
&key,
|
||||
&RegisterRequest {
|
||||
protocol_version: REGISTRY_PROTOCOL_VERSION,
|
||||
realm_id: realm,
|
||||
public_key: coa_registry_proto::sign::encode_public_key(&key.verifying_key()),
|
||||
listing_hash: listing.hash(),
|
||||
listing,
|
||||
capabilities_hash: advertised.advert_hash(),
|
||||
capabilities: advertised,
|
||||
population: Population {
|
||||
players: 0,
|
||||
bots: 0,
|
||||
capacity: None,
|
||||
},
|
||||
},
|
||||
clock(),
|
||||
)
|
||||
.unwrap();
|
||||
// Always unpublish this isolated identity, including after a failed assertion.
|
||||
struct Lease(RegistryClient, RealmId, ed25519_dalek::SigningKey);
|
||||
impl Drop for Lease {
|
||||
fn drop(&mut self) {
|
||||
let ts = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_secs() as i64;
|
||||
let _ = self.0.unpublish(&self.2, &self.1, ts);
|
||||
}
|
||||
}
|
||||
let _lease = Lease(registry, realm, key.clone());
|
||||
let temporary = tempdir().unwrap();
|
||||
let settings_dir = temporary.path().join("registry");
|
||||
let local_id = "srv-live-candidate";
|
||||
let mut cfg = settings::RegistrySettings::default();
|
||||
cfg.realms.insert(
|
||||
local_id.into(),
|
||||
settings::PublishConfig {
|
||||
realm_id: Some(realm),
|
||||
enabled: true,
|
||||
display_name: "Candidate".into(),
|
||||
description: String::new(),
|
||||
language: "en".into(),
|
||||
region: None,
|
||||
existing_only: false,
|
||||
allow_portable: true,
|
||||
route: None,
|
||||
},
|
||||
);
|
||||
settings::save(&settings_dir, &cfg).unwrap();
|
||||
let installed = root.clone();
|
||||
let runtime = Arc::new(
|
||||
PortableRuntime::start(
|
||||
&temporary.path().join("portable"),
|
||||
Box::new(move || vec![("live-candidate".into(), installed.clone())]),
|
||||
None,
|
||||
)
|
||||
.unwrap(),
|
||||
);
|
||||
let installed = root.clone();
|
||||
let backend = LocalBackend::new(
|
||||
runtime.clone(),
|
||||
&settings_dir,
|
||||
temporary.path().join("descriptors"),
|
||||
Arc::new(move || vec![("live-candidate".into(), installed.clone())]),
|
||||
);
|
||||
assert_eq!(
|
||||
backend.info(local_id).unwrap().ruleset,
|
||||
crate::portable::Ruleset::Coa
|
||||
);
|
||||
let service = Arc::new(HostService::new(
|
||||
local_id,
|
||||
realm,
|
||||
Arc::new(Mutex::new(ControlStore::open_in_memory().unwrap())),
|
||||
Arc::new(backend),
|
||||
));
|
||||
let relay = crate::control::relay_link::RelayLink::start_with_local_ports(
|
||||
coordinator_url(&base, "/relay/v1/host").unwrap(),
|
||||
realm,
|
||||
key.clone(),
|
||||
auth_port,
|
||||
Some(57638),
|
||||
);
|
||||
service.set_relay(relay.clone());
|
||||
let mut link = HostLink::start(
|
||||
coordinator_url(&base, "/coord/v1/host").unwrap(),
|
||||
realm,
|
||||
key.clone(),
|
||||
service,
|
||||
Arc::new(clock),
|
||||
);
|
||||
let deadline = Instant::now() + Duration::from_secs(20);
|
||||
while !link.status().connected && Instant::now() < deadline {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
assert!(link.status().connected, "{:?}", link.status());
|
||||
let player_secrets = Arc::new(MemoryStore::default());
|
||||
let player = Arc::new(
|
||||
PlayerControl::open(&temporary.path().join("player"), player_secrets.clone()).unwrap(),
|
||||
);
|
||||
let target = RealmTarget {
|
||||
realm_id: realm,
|
||||
key: key.verifying_key(),
|
||||
coordinator: coordinator_url(&base, "/coord/v1/player").unwrap(),
|
||||
};
|
||||
let mut channel = player.connect(&target).unwrap();
|
||||
let account = player
|
||||
.ensure_account(&mut channel, &realm, Some("REALPORTABLEGATE"))
|
||||
.unwrap();
|
||||
let until = Instant::now() + Duration::from_secs(20);
|
||||
while !relay.status().connected && Instant::now() < until {
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
assert!(relay.status().connected, "{:?}", relay.status());
|
||||
let allocation = player.allocate_relay(&mut channel).unwrap();
|
||||
use std::io::{Read, Write};
|
||||
let mut auth_client =
|
||||
std::net::TcpStream::connect((allocation.relay_host.as_str(), allocation.auth_port))
|
||||
.unwrap();
|
||||
auth_client
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let username = account.username.to_ascii_uppercase();
|
||||
let mut challenge = vec![0, 0];
|
||||
challenge.extend_from_slice(&((30 + username.len()) as u16).to_le_bytes());
|
||||
challenge.extend_from_slice(b"WoW\0");
|
||||
challenge.extend_from_slice(&[3, 3, 5]);
|
||||
challenge.extend_from_slice(&12340u16.to_le_bytes());
|
||||
challenge.extend_from_slice(b"68x\0niW\0SUne");
|
||||
challenge.extend_from_slice(&0u32.to_le_bytes());
|
||||
challenge.extend_from_slice(&[127, 0, 0, 1]);
|
||||
challenge.push(username.len() as u8);
|
||||
challenge.extend_from_slice(username.as_bytes());
|
||||
auth_client.write_all(&challenge).unwrap();
|
||||
let mut response = [0u8; 35];
|
||||
auth_client.read_exact(&mut response).unwrap();
|
||||
assert_eq!(
|
||||
&response[..3],
|
||||
&[0, 0, 0],
|
||||
"Real authserver must accept the relay challenge"
|
||||
);
|
||||
assert!(response[3..].iter().any(|byte| *byte != 0));
|
||||
drop(auth_client);
|
||||
let mut world_client =
|
||||
std::net::TcpStream::connect((allocation.relay_host.as_str(), allocation.world_port))
|
||||
.unwrap();
|
||||
world_client
|
||||
.set_read_timeout(Some(Duration::from_secs(10)))
|
||||
.unwrap();
|
||||
let mut response = [0u8; 44];
|
||||
world_client.read_exact(&mut response).unwrap();
|
||||
assert_eq!(
|
||||
&response[2..4],
|
||||
&[0xec, 0x01],
|
||||
"Real worldserver authentication challenge must pass through relay"
|
||||
);
|
||||
drop(world_client);
|
||||
|
||||
let mut owner = Store::open_in_memory().unwrap();
|
||||
let profile = owner.default_profile().unwrap();
|
||||
let character = fixture_character(&databases, &mut owner, profile);
|
||||
let mut model = owner.load_current(character).unwrap();
|
||||
model.progression.level = 50;
|
||||
model.progression.xp = 0;
|
||||
let hidden_items = model.items.len();
|
||||
let owner_path = temporary.path().join("player-portable");
|
||||
let owner_runtime =
|
||||
Arc::new(PortableRuntime::start(&owner_path, Box::new(Vec::new), None).unwrap());
|
||||
owner_runtime
|
||||
.call(move |service| {
|
||||
let profile = service.owner_profile();
|
||||
service
|
||||
.owner_mut()
|
||||
.host_install_copy(profile, &model, 1, "test-owner")
|
||||
.unwrap();
|
||||
})
|
||||
.unwrap();
|
||||
let public_realm = realm.to_string();
|
||||
let (bundle, session, revision) = owner_runtime
|
||||
.call(move |service| {
|
||||
service.prepare_remote_transfer(&character.to_string(), &public_realm)
|
||||
})
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let transferred = player
|
||||
.transfer_owned_character(
|
||||
&mut channel,
|
||||
session,
|
||||
character.as_uuid(),
|
||||
"coa",
|
||||
revision,
|
||||
&bundle.payload,
|
||||
bundle.content_hash,
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(transferred.session_id, session);
|
||||
assert!(transferred.local_guid > 0);
|
||||
assert_eq!(
|
||||
number(
|
||||
&databases.b,
|
||||
&format!(
|
||||
"SELECT level FROM acore_characters.characters WHERE guid = {}",
|
||||
transferred.local_guid
|
||||
)
|
||||
),
|
||||
50
|
||||
);
|
||||
let retry = player
|
||||
.transfer_owned_character(
|
||||
&mut channel,
|
||||
session,
|
||||
character.as_uuid(),
|
||||
"coa",
|
||||
revision,
|
||||
&bundle.payload,
|
||||
bundle.content_hash,
|
||||
BTreeMap::new(),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
(retry.local_guid, retry.session_id),
|
||||
(transferred.local_guid, transferred.session_id)
|
||||
);
|
||||
let pending = player.pending_remote_sessions().unwrap().pop().unwrap();
|
||||
let mut console = server.ra();
|
||||
console
|
||||
.run(&format!("botcmd spawnbot {}", transferred.local_guid))
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let sid = crate::portable::SessionId::from_uuid(session).unwrap();
|
||||
let mut playing = false;
|
||||
while Instant::now() < deadline {
|
||||
player
|
||||
.sync_remote_session(&pending, &owner_runtime)
|
||||
.unwrap();
|
||||
let opened = owner_runtime
|
||||
.call(move |service| {
|
||||
service
|
||||
.owner()
|
||||
.owner_session_info(sid)
|
||||
.unwrap()
|
||||
.is_some_and(|row| row.0 == "open")
|
||||
})
|
||||
.unwrap();
|
||||
if opened
|
||||
&& crate::portable::session::live_session::online(
|
||||
&databases.b,
|
||||
transferred.local_guid,
|
||||
)
|
||||
&& console
|
||||
.run(&format!("portable status {}", transferred.local_guid))
|
||||
.unwrap_or_default()
|
||||
.contains("gated 0")
|
||||
{
|
||||
playing = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
assert!(
|
||||
playing,
|
||||
"The actual player never received the Owner's durable baseline acknowledgement"
|
||||
);
|
||||
let name =
|
||||
crate::portable::session::live_session::name_of(&databases.b, transferred.local_guid);
|
||||
console.run(&format!("character level {name} 51")).unwrap();
|
||||
// Reopen both Player databases before receiving progress. Recovery uses the pinned realm key and durable session.
|
||||
owner_runtime.shutdown();
|
||||
drop(owner_runtime);
|
||||
drop(player);
|
||||
let owner_runtime =
|
||||
Arc::new(PortableRuntime::start(&owner_path, Box::new(Vec::new), None).unwrap());
|
||||
let player = Arc::new(
|
||||
PlayerControl::open(&temporary.path().join("player"), player_secrets).unwrap(),
|
||||
);
|
||||
let pending = player.pending_remote_sessions().unwrap().pop().unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let mut saved = false;
|
||||
while Instant::now() < deadline {
|
||||
player
|
||||
.sync_remote_session(&pending, &owner_runtime)
|
||||
.unwrap();
|
||||
saved = owner_runtime
|
||||
.call(move |service| {
|
||||
service
|
||||
.owner()
|
||||
.load_current(character)
|
||||
.unwrap()
|
||||
.progression
|
||||
.level
|
||||
== 51
|
||||
})
|
||||
.unwrap();
|
||||
if saved {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
assert!(saved, "Real in-game progress was not saved through the public coordinator after Manager restart");
|
||||
assert!(crate::portable::session::live_session::online(
|
||||
&databases.b,
|
||||
transferred.local_guid
|
||||
));
|
||||
console
|
||||
.run(&format!("botcmd despawn {}", transferred.local_guid))
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let mut completed = false;
|
||||
while Instant::now() < deadline {
|
||||
if player
|
||||
.sync_remote_session(&pending, &owner_runtime)
|
||||
.unwrap()
|
||||
{
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
assert!(
|
||||
completed,
|
||||
"The final progress acknowledgement never completed"
|
||||
);
|
||||
let next = player.pending_remote_sessions().unwrap().pop().unwrap();
|
||||
assert_ne!(
|
||||
next.session_id, session,
|
||||
"The next login must remain monitored"
|
||||
);
|
||||
assert!(owner_runtime
|
||||
.call(move |service| service.remote_session_idle(next.session_id))
|
||||
.unwrap()
|
||||
.unwrap());
|
||||
// A second login without pressing Play is still supervised and saved.
|
||||
PlayerControl::start_remote_session_sync(&player, &owner_runtime);
|
||||
console
|
||||
.run(&format!("botcmd spawnbot {}", transferred.local_guid))
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let next_sid = crate::portable::SessionId::from_uuid(next.session_id).unwrap();
|
||||
let mut opened = false;
|
||||
while Instant::now() < deadline {
|
||||
opened = owner_runtime
|
||||
.call(move |service| {
|
||||
service
|
||||
.owner()
|
||||
.owner_session_info(next_sid)
|
||||
.unwrap()
|
||||
.is_some_and(|row| row.0 == "open")
|
||||
})
|
||||
.unwrap();
|
||||
if opened {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
assert!(
|
||||
opened
|
||||
&& crate::portable::session::live_session::online(
|
||||
&databases.b,
|
||||
transferred.local_guid
|
||||
)
|
||||
);
|
||||
console.run(&format!("character level {name} 52")).unwrap();
|
||||
console
|
||||
.run(&format!("botcmd despawn {}", transferred.local_guid))
|
||||
.unwrap();
|
||||
let deadline = Instant::now() + Duration::from_secs(90);
|
||||
let mut completed = false;
|
||||
while Instant::now() < deadline {
|
||||
if player
|
||||
.pending_remote_sessions()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|pending| pending.session_id != next.session_id)
|
||||
{
|
||||
completed = true;
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
assert!(
|
||||
completed,
|
||||
"The second login's final checkpoint was not acknowledged"
|
||||
);
|
||||
player
|
||||
.leave_idle_remote_session(character.as_uuid(), &owner_runtime)
|
||||
.unwrap();
|
||||
assert!(player.pending_remote_sessions().unwrap().is_empty());
|
||||
owner_runtime
|
||||
.call(move |service| {
|
||||
assert!(service.remote_session_closed(session).unwrap());
|
||||
let current = service.owner().load_current(character).unwrap();
|
||||
assert_eq!(current.progression.level, 52);
|
||||
assert!(
|
||||
current.items.len() >= hidden_items,
|
||||
"Hidden equipment was lost"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
owner_runtime.shutdown();
|
||||
channel.close();
|
||||
link.stop();
|
||||
relay.stop();
|
||||
runtime.shutdown();
|
||||
drop(server);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unreadable_or_unknown_realm_fails_closed_in_info() {
|
||||
let dir = tempdir().unwrap();
|
||||
@@ -383,6 +1010,25 @@ mod tests {
|
||||
Arc::new(move || vec![("test1".to_string(), root_clone.clone())]),
|
||||
);
|
||||
|
||||
assert_eq!(backend.auth_port("srv-test1:coa").unwrap(), 3724);
|
||||
fs::write(
|
||||
root.join("Settings/repack.json"),
|
||||
r#"{"worldPort":8085,"authPort":57324}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(backend.auth_port("srv-test1:coa").unwrap(), 57324);
|
||||
assert_eq!(backend.auth_port("srv-test1:wildcard").unwrap(), 57324);
|
||||
fs::write(
|
||||
root.join("Settings/repack.json"),
|
||||
r#"{"worldPort":8085,"authPort":0}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert!(backend.auth_port("srv-test1:coa").is_err());
|
||||
fs::write(
|
||||
root.join("Settings/repack.json"),
|
||||
r#"{"worldPort":8085,"authPort":57324}"#,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(backend.world_port("srv-test1:coa").unwrap(), 8085);
|
||||
assert_eq!(backend.world_port("srv-test1:wildcard").unwrap(), 8086);
|
||||
|
||||
|
||||
@@ -303,7 +303,21 @@ fn attempt_route_discovery(
|
||||
let local_auth = std::env::var("COA_OVERRIDE_LOCAL_AUTH_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(coa_control_proto::relay::LOCAL_AUTH_PORT);
|
||||
.map(Ok)
|
||||
.unwrap_or_else(|| service.local_auth_port());
|
||||
let local_auth = match local_auth {
|
||||
Ok(port) => port,
|
||||
Err(error) => {
|
||||
set_status(
|
||||
status,
|
||||
RouteStatus::RelayOnly {
|
||||
reason: format!("Cannot resolve local auth port: {error}"),
|
||||
},
|
||||
);
|
||||
service.set_direct_route(None);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let local_world = match std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
|
||||
@@ -159,7 +159,7 @@ fn session(
|
||||
) -> Result<(), LinkError> {
|
||||
let mut ws = transport::connect(url, POLL)?;
|
||||
let nonce = match transport::next_frame(&mut ws, Duration::from_secs(15))? {
|
||||
Frame::Challenge { nonce, .. } => nonce,
|
||||
Frame::Challenge { protocol, nonce } if protocol == coord::COORDINATOR_PROTOCOL_VERSION => nonce,
|
||||
_ => return Err(LinkError::Protocol("a challenge was expected".into())),
|
||||
};
|
||||
transport::send_text(
|
||||
|
||||
@@ -24,6 +24,7 @@ pub struct HostControlRealm {
|
||||
pub local_id: String,
|
||||
pub realm_id: String,
|
||||
pub link: LinkStatus,
|
||||
pub relay: Option<crate::control::relay_link::RelayStatus>,
|
||||
}
|
||||
|
||||
pub struct HostControl {
|
||||
@@ -35,6 +36,7 @@ pub struct HostControl {
|
||||
struct Running {
|
||||
realm_id: RealmId,
|
||||
url: String,
|
||||
ports: Option<(u16, u16)>,
|
||||
link: HostLink,
|
||||
#[allow(dead_code)]
|
||||
relay_link: Option<Arc<crate::control::relay_link::RelayLink>>,
|
||||
@@ -83,7 +85,7 @@ impl HostControl {
|
||||
running.retain(|id, r| {
|
||||
wanted
|
||||
.get(id)
|
||||
.is_some_and(|(realm, url, _, _)| *realm == r.realm_id && *url == r.url)
|
||||
.is_some_and(|(realm, url, _, _)| *realm == r.realm_id && *url == r.url && backend.auth_port(id).ok().zip(backend.world_port(id).ok()) == r.ports)
|
||||
});
|
||||
for (local_id, (realm_id, url, base, key)) in wanted {
|
||||
if running.contains_key(&local_id) {
|
||||
@@ -95,15 +97,17 @@ impl HostControl {
|
||||
store.clone(),
|
||||
backend.clone(),
|
||||
));
|
||||
let relay_link = match service.local_world_port() {
|
||||
Ok(world_port) => {
|
||||
let ports = service.local_auth_port().ok().zip(service.local_world_port().ok());
|
||||
let relay_link = match ports {
|
||||
Some((auth_port, world_port)) => {
|
||||
crate::control::transport::relay_url(&base, "/relay/v1/host").map(
|
||||
|r_url| {
|
||||
let rl =
|
||||
crate::control::relay_link::RelayLink::start_with_ports(
|
||||
crate::control::relay_link::RelayLink::start_with_local_ports(
|
||||
r_url,
|
||||
realm_id,
|
||||
key.clone(),
|
||||
auth_port,
|
||||
Some(world_port),
|
||||
);
|
||||
service.set_relay(rl.clone());
|
||||
@@ -111,8 +115,8 @@ impl HostControl {
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(e) => {
|
||||
eprintln!("Relay link unavailable for {local_id}: cannot resolve world port: {e}");
|
||||
None => {
|
||||
tracing::warn!(%local_id, "relay is waiting for valid local auth and world ports");
|
||||
None
|
||||
}
|
||||
};
|
||||
@@ -128,6 +132,7 @@ impl HostControl {
|
||||
Running {
|
||||
realm_id,
|
||||
url,
|
||||
ports,
|
||||
link,
|
||||
relay_link,
|
||||
direct_route,
|
||||
@@ -144,6 +149,7 @@ impl HostControl {
|
||||
local_id: id.clone(),
|
||||
realm_id: r.realm_id.to_string(),
|
||||
link: r.link.status(),
|
||||
relay: r.relay_link.as_ref().map(|relay| relay.status()),
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -20,6 +20,8 @@ use super::store::{AccountKind, ControlStore};
|
||||
use super::transport::{self, LinkError};
|
||||
use crate::{Error, Result};
|
||||
|
||||
mod portable_sync;
|
||||
|
||||
/// Where and as whom to reach a realm.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RealmTarget {
|
||||
@@ -412,9 +414,11 @@ impl PlayerControl {
|
||||
}
|
||||
|
||||
/// Transfer a portable character to a remote realm over the verified control channel (Phase 12.1).
|
||||
pub fn transfer_character(
|
||||
fn transfer_with_id(
|
||||
&self,
|
||||
ch: &mut PlayerChannel,
|
||||
transfer_id: Uuid,
|
||||
owned_session: bool,
|
||||
character_id: Uuid,
|
||||
ruleset: &str,
|
||||
canonical_revision: u64,
|
||||
@@ -422,16 +426,27 @@ impl PlayerControl {
|
||||
content_hash: [u8; 32],
|
||||
collections: BTreeMap<String, Vec<u32>>,
|
||||
) -> R<RemoteTransferOutcome> {
|
||||
let transfer_id = Uuid::now_v7();
|
||||
let content_hash_str = hex::encode(content_hash);
|
||||
let offer = Request::TransferOffer {
|
||||
transfer_id,
|
||||
character_id,
|
||||
ruleset: ruleset.to_string(),
|
||||
canonical_revision,
|
||||
content_hash: content_hash_str.clone(),
|
||||
total_size: payload.len(),
|
||||
collections,
|
||||
let offer = if owned_session {
|
||||
Request::OwnedTransferOffer {
|
||||
transfer_id,
|
||||
character_id,
|
||||
ruleset: ruleset.to_string(),
|
||||
canonical_revision,
|
||||
content_hash: content_hash_str.clone(),
|
||||
total_size: payload.len(),
|
||||
collections,
|
||||
}
|
||||
} else {
|
||||
Request::TransferOffer {
|
||||
transfer_id,
|
||||
character_id,
|
||||
ruleset: ruleset.to_string(),
|
||||
canonical_revision,
|
||||
content_hash: content_hash_str.clone(),
|
||||
total_size: payload.len(),
|
||||
collections,
|
||||
}
|
||||
};
|
||||
let r = ch.request(&offer)?;
|
||||
let mut offset = match r {
|
||||
@@ -456,7 +471,21 @@ impl PlayerControl {
|
||||
notes,
|
||||
});
|
||||
}
|
||||
Response::Error { code, message } => return Err(ControlFail::App { code, message }),
|
||||
Response::Error { code, message } => {
|
||||
if owned_session
|
||||
&& matches!(
|
||||
code,
|
||||
AppError::Incompatible
|
||||
| AppError::NotYours
|
||||
| AppError::AlreadyClaimed
|
||||
| AppError::Invalid
|
||||
)
|
||||
{
|
||||
self.store()?
|
||||
.player_remote_session_remove(character_id, transfer_id)?;
|
||||
}
|
||||
return Err(ControlFail::App { code, message });
|
||||
}
|
||||
_ => {
|
||||
return Err(ControlFail::Link(LinkError::Protocol(
|
||||
"unexpected response to transfer offer".into(),
|
||||
|
||||
@@ -0,0 +1,290 @@
|
||||
use super::*;
|
||||
use crate::control::store::PlayerRemoteSession;
|
||||
use crate::portable::service::PortableRuntime;
|
||||
use base64::engine::general_purpose::STANDARD as B64;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
fn protocol_error(message: &str) -> ControlFail {
|
||||
ControlFail::Link(LinkError::Protocol(message.into()))
|
||||
}
|
||||
|
||||
impl PlayerControl {
|
||||
/// Compatibility helper for transport tests. Interactive play uses the Owner's durable session id.
|
||||
pub fn transfer_character(
|
||||
&self,
|
||||
channel: &mut PlayerChannel,
|
||||
character: Uuid,
|
||||
ruleset: &str,
|
||||
revision: u64,
|
||||
payload: &[u8],
|
||||
hash: [u8; 32],
|
||||
collections: BTreeMap<String, Vec<u32>>,
|
||||
) -> R<RemoteTransferOutcome> {
|
||||
self.transfer_with_id(
|
||||
channel,
|
||||
Uuid::now_v7(),
|
||||
false,
|
||||
character,
|
||||
ruleset,
|
||||
revision,
|
||||
payload,
|
||||
hash,
|
||||
collections,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn transfer_owned_character(
|
||||
&self,
|
||||
channel: &mut PlayerChannel,
|
||||
session: Uuid,
|
||||
character: Uuid,
|
||||
ruleset: &str,
|
||||
revision: u64,
|
||||
payload: &[u8],
|
||||
hash: [u8; 32],
|
||||
collections: BTreeMap<String, Vec<u32>>,
|
||||
) -> R<RemoteTransferOutcome> {
|
||||
self.store()?
|
||||
.player_remote_session_set(&PlayerRemoteSession {
|
||||
character_id: character,
|
||||
realm_id: channel.realm_id.to_string(),
|
||||
session_id: session,
|
||||
public_key: coord::encode_public_key(&channel.realm_key),
|
||||
coordinator: channel.coordinator.clone(),
|
||||
})?;
|
||||
let result = self.transfer_with_id(
|
||||
channel,
|
||||
session,
|
||||
true,
|
||||
character,
|
||||
ruleset,
|
||||
revision,
|
||||
payload,
|
||||
hash,
|
||||
collections,
|
||||
);
|
||||
if let Ok(outcome) = &result {
|
||||
if outcome.session_id != session {
|
||||
return Err(protocol_error(
|
||||
"The Host returned another portable session.",
|
||||
));
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub fn pending_remote_sessions(&self) -> R<Vec<PlayerRemoteSession>> {
|
||||
Ok(self.store()?.player_remote_sessions()?)
|
||||
}
|
||||
|
||||
/// An idle, already-saved realm may be left; an uncertain or open session must finish first.
|
||||
pub fn leave_idle_remote_session(&self, character: Uuid, runtime: &PortableRuntime) -> R<()> {
|
||||
for pending in self
|
||||
.pending_remote_sessions()?
|
||||
.into_iter()
|
||||
.filter(|pending| pending.character_id == character)
|
||||
{
|
||||
let sid = pending.session_id;
|
||||
let idle = runtime
|
||||
.call(move |service| service.remote_session_idle(sid))
|
||||
.map_err(|error| ControlFail::Local(error.to_string()))?
|
||||
.map_err(|error| ControlFail::Local(error.message))?;
|
||||
if !idle {
|
||||
return Err(ControlFail::Local("Progress from the previous realm is still being saved. Keep both Managers running until it finishes.".into()));
|
||||
}
|
||||
self.store()?.player_remote_session_remove(character, sid)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Persist the Owner's acknowledgement before acknowledging it to the Host. A lost reply is retried without making another revision.
|
||||
pub fn sync_remote_session(
|
||||
&self,
|
||||
pending: &PlayerRemoteSession,
|
||||
runtime: &PortableRuntime,
|
||||
) -> R<bool> {
|
||||
let mut channel = self.remote_session_channel(pending)?;
|
||||
self.sync_remote_session_on_channel(pending, runtime, &mut channel)
|
||||
}
|
||||
|
||||
fn remote_session_channel(&self, pending: &PlayerRemoteSession) -> R<PlayerChannel> {
|
||||
let realm = RealmId::parse(&pending.realm_id)
|
||||
.map_err(|error| ControlFail::Local(error.to_string()))?;
|
||||
let key = coord::decode_public_key(&pending.public_key)
|
||||
.map_err(|error| protocol_error(&error.to_string()))?;
|
||||
let target = RealmTarget {
|
||||
realm_id: realm,
|
||||
key,
|
||||
coordinator: pending.coordinator.clone(),
|
||||
};
|
||||
self.connect(&target)
|
||||
}
|
||||
|
||||
fn sync_remote_session_on_channel(
|
||||
&self,
|
||||
pending: &PlayerRemoteSession,
|
||||
runtime: &PortableRuntime,
|
||||
channel: &mut PlayerChannel,
|
||||
) -> R<bool> {
|
||||
let character_id = pending.character_id;
|
||||
let session_id = pending.session_id;
|
||||
for _ in 0..16 {
|
||||
let (kind, total_size, content_hash) = match channel.request(&Request::SessionPoll {
|
||||
character_id,
|
||||
session_id,
|
||||
})? {
|
||||
Response::SessionIdle { closed } => {
|
||||
if closed {
|
||||
let locally_closed = runtime
|
||||
.call(move |service| service.remote_session_closed(session_id))
|
||||
.map_err(|error| ControlFail::Local(error.to_string()))?
|
||||
.map_err(|error| ControlFail::Local(error.message))?;
|
||||
if !locally_closed {
|
||||
return Err(protocol_error(
|
||||
"The Host closed a session before the Owner saved it.",
|
||||
));
|
||||
}
|
||||
let realm = pending.realm_id.clone();
|
||||
let next = runtime
|
||||
.call(move |service| service.next_remote_session(character_id, &realm))
|
||||
.map_err(|error| ControlFail::Local(error.to_string()))?
|
||||
.map_err(|error| ControlFail::Local(error.message))?;
|
||||
if let Some(next) = next {
|
||||
self.store()?.player_remote_session_advance(
|
||||
character_id,
|
||||
session_id,
|
||||
next,
|
||||
)?;
|
||||
} else {
|
||||
self.store()?
|
||||
.player_remote_session_remove(character_id, session_id)?;
|
||||
}
|
||||
return Ok(true);
|
||||
}
|
||||
return Ok(false);
|
||||
}
|
||||
Response::SessionPending {
|
||||
kind,
|
||||
total_size,
|
||||
content_hash,
|
||||
} if total_size > 0
|
||||
&& total_size <= app::MAX_SESSION_BYTES
|
||||
&& content_hash.len() == 64 =>
|
||||
{
|
||||
(kind, total_size, content_hash)
|
||||
}
|
||||
Response::Error { code, message } => {
|
||||
return Err(ControlFail::App { code, message })
|
||||
}
|
||||
_ => return Err(protocol_error("Invalid portable session metadata.")),
|
||||
};
|
||||
let mut bytes = Vec::with_capacity(total_size);
|
||||
while bytes.len() < total_size {
|
||||
match channel.request(&Request::SessionRead {
|
||||
character_id,
|
||||
session_id,
|
||||
message_hash: content_hash.clone(),
|
||||
offset: bytes.len(),
|
||||
})? {
|
||||
Response::SessionData { offset, data } if offset == bytes.len() => {
|
||||
let chunk = B64
|
||||
.decode(data)
|
||||
.map_err(|_| protocol_error("Invalid portable session chunk."))?;
|
||||
if chunk.is_empty() || chunk.len() > total_size - bytes.len() {
|
||||
return Err(protocol_error("Invalid portable session chunk bounds."));
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
Response::Error { code, message } => {
|
||||
return Err(ControlFail::App { code, message })
|
||||
}
|
||||
_ => return Err(protocol_error("Invalid portable session chunk offset.")),
|
||||
}
|
||||
}
|
||||
if hex::encode(Sha256::digest(&bytes)) != content_hash {
|
||||
return Err(protocol_error("The portable session message hash changed."));
|
||||
}
|
||||
let remote_realm = pending.realm_id.clone();
|
||||
let ack = runtime
|
||||
.call(move |service| {
|
||||
service.apply_remote_session_message(
|
||||
character_id,
|
||||
session_id,
|
||||
&remote_realm,
|
||||
kind,
|
||||
&bytes,
|
||||
)
|
||||
})
|
||||
.map_err(|error| ControlFail::Local(error.to_string()))?
|
||||
.map_err(|error| ControlFail::Local(error.message))?;
|
||||
let mut offset = 0;
|
||||
while offset < ack.len() {
|
||||
let end = ack.len().min(offset + 24 * 1024);
|
||||
match channel.request(&Request::SessionAck {
|
||||
character_id,
|
||||
session_id,
|
||||
message_hash: content_hash.clone(),
|
||||
offset,
|
||||
total_size: ack.len(),
|
||||
data: B64.encode(&ack[offset..end]),
|
||||
})? {
|
||||
Response::SessionAcked {
|
||||
received_size,
|
||||
complete,
|
||||
} if received_size > offset
|
||||
&& received_size <= ack.len()
|
||||
&& (!complete || received_size == ack.len()) =>
|
||||
{
|
||||
offset = received_size
|
||||
}
|
||||
Response::Error { code, message } => {
|
||||
return Err(ControlFail::App { code, message })
|
||||
}
|
||||
_ => return Err(protocol_error("Invalid portable acknowledgement offset.")),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(false)
|
||||
}
|
||||
|
||||
/// Independent of the visible page and the game process. Durable sessions resume after a Manager restart.
|
||||
pub fn start_remote_session_sync(player: &Arc<Self>, runtime: &Arc<PortableRuntime>) {
|
||||
let player = Arc::downgrade(player);
|
||||
let runtime = Arc::downgrade(runtime);
|
||||
let _ = std::thread::Builder::new().name("portable-remote-sync".into()).spawn(move || {
|
||||
let mut errors = std::collections::HashMap::<Uuid, String>::new();
|
||||
let mut channels = std::collections::HashMap::<String, PlayerChannel>::new();
|
||||
loop {
|
||||
{
|
||||
let (Some(player), Some(runtime)) = (player.upgrade(), runtime.upgrade()) else { return };
|
||||
if let Ok(sessions) = player.pending_remote_sessions() {
|
||||
let wanted: std::collections::HashSet<_> = sessions.iter().map(|session| format!("{}|{}|{}", session.realm_id, session.public_key, session.coordinator)).collect();
|
||||
channels.retain(|key, _| wanted.contains(key));
|
||||
for session in sessions {
|
||||
let key = format!("{}|{}|{}", session.realm_id, session.public_key, session.coordinator);
|
||||
let result = (|| {
|
||||
if !channels.contains_key(&key) {
|
||||
if channels.len() >= 8 { channels.clear(); }
|
||||
channels.insert(key.clone(), player.remote_session_channel(&session)?);
|
||||
}
|
||||
player.sync_remote_session_on_channel(&session, &runtime, channels.get_mut(&key).expect("inserted"))
|
||||
})();
|
||||
match result {
|
||||
Ok(_) => { errors.remove(&session.character_id); },
|
||||
Err(error) => {
|
||||
channels.remove(&key);
|
||||
let message = error.to_string();
|
||||
if errors.get(&session.character_id) != Some(&message) {
|
||||
tracing::warn!(character = %session.character_id, error = %message, "remote portable progress is pending; retrying");
|
||||
errors.insert(session.character_id, message);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(2));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,9 @@ use super::identity::PlayerIdentity;
|
||||
use super::transport::{self, LinkError, Ws};
|
||||
|
||||
pub struct PlayerChannel {
|
||||
pub(crate) realm_id: RealmId,
|
||||
pub(crate) realm_key: VerifyingKey,
|
||||
pub(crate) coordinator: String,
|
||||
ws: Ws,
|
||||
channel: Channel,
|
||||
conn: u32,
|
||||
@@ -35,7 +38,11 @@ impl PlayerChannel {
|
||||
Duration::from_millis(500),
|
||||
)?;
|
||||
let nonce = match transport::next_frame(&mut ws, STEP)? {
|
||||
Frame::Challenge { nonce, .. } => nonce,
|
||||
Frame::Challenge { protocol, nonce }
|
||||
if protocol == coord::COORDINATOR_PROTOCOL_VERSION =>
|
||||
{
|
||||
nonce
|
||||
}
|
||||
_ => return Err(LinkError::Protocol("a challenge was expected".into())),
|
||||
};
|
||||
transport::send_text(
|
||||
@@ -67,7 +74,14 @@ impl PlayerChannel {
|
||||
{
|
||||
transport::send_binary(&mut ws, f)?;
|
||||
}
|
||||
Ok(PlayerChannel { ws, channel, conn })
|
||||
Ok(PlayerChannel {
|
||||
ws,
|
||||
channel,
|
||||
conn,
|
||||
realm_id: *realm,
|
||||
realm_key: *realm_key,
|
||||
coordinator: coordinator.into(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn connection(&self) -> u32 {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
//! Outbound Host <-> Game Relay tunnel link (Phase 13).
|
||||
//! Maintains outbound connectivity from Host Manager to the Game Relay,
|
||||
//! opening local TCP connections strictly to 127.0.0.1:3724 (Auth) and 127.0.0.1:8085 (World).
|
||||
//! opening local TCP connections only to the installed realm's configured loopback Auth and World ports.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Read, Write};
|
||||
@@ -79,6 +79,22 @@ impl RelayLink {
|
||||
realm_id: RealmId,
|
||||
key: SigningKey,
|
||||
local_world_port: Option<u16>,
|
||||
) -> Arc<Self> {
|
||||
Self::start_with_local_ports(
|
||||
url,
|
||||
realm_id,
|
||||
key,
|
||||
RelayTarget::Auth.local_port(),
|
||||
local_world_port,
|
||||
)
|
||||
}
|
||||
|
||||
pub fn start_with_local_ports(
|
||||
url: String,
|
||||
realm_id: RealmId,
|
||||
key: SigningKey,
|
||||
local_auth_port: u16,
|
||||
local_world_port: Option<u16>,
|
||||
) -> Arc<Self> {
|
||||
let stop = Arc::new(AtomicBool::new(false));
|
||||
let status = Arc::new(Mutex::new(RelayStatus::default()));
|
||||
@@ -88,7 +104,18 @@ impl RelayLink {
|
||||
let st = status.clone();
|
||||
let join = std::thread::Builder::new()
|
||||
.name("control-relay-host".into())
|
||||
.spawn(move || run(&url, realm_id, &key, alloc_rx, &s, &st, local_world_port))
|
||||
.spawn(move || {
|
||||
run(
|
||||
&url,
|
||||
realm_id,
|
||||
&key,
|
||||
alloc_rx,
|
||||
&s,
|
||||
&st,
|
||||
local_auth_port,
|
||||
local_world_port,
|
||||
)
|
||||
})
|
||||
.ok();
|
||||
|
||||
Arc::new(RelayLink {
|
||||
@@ -136,12 +163,25 @@ fn run(
|
||||
)>,
|
||||
stop: &AtomicBool,
|
||||
status: &Mutex<RelayStatus>,
|
||||
local_auth_port: u16,
|
||||
local_world_port: Option<u16>,
|
||||
) {
|
||||
let mut failures = 0u32;
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
let began = Instant::now();
|
||||
let result = session(url, realm, key, &alloc_rx, stop, status, local_world_port);
|
||||
let result = session(
|
||||
url,
|
||||
realm,
|
||||
key,
|
||||
&alloc_rx,
|
||||
stop,
|
||||
status,
|
||||
local_auth_port,
|
||||
local_world_port,
|
||||
);
|
||||
if let Err(error) = &result {
|
||||
tracing::warn!(%realm, %error, "game relay connection lost; retrying");
|
||||
}
|
||||
set_status(status, |s| {
|
||||
s.connected = false;
|
||||
s.active_streams = 0;
|
||||
@@ -173,6 +213,17 @@ fn run(
|
||||
|
||||
struct StreamWorker {
|
||||
tx: Sender<TunnelMsg>,
|
||||
reset_on_drop: bool,
|
||||
shutdown: Arc<TcpStream>,
|
||||
}
|
||||
|
||||
impl Drop for StreamWorker {
|
||||
fn drop(&mut self) {
|
||||
if self.reset_on_drop {
|
||||
let _ = self.shutdown.shutdown(std::net::Shutdown::Both);
|
||||
let _ = self.tx.send(TunnelMsg::Reset { stream_id: 0 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn session(
|
||||
@@ -186,6 +237,7 @@ fn session(
|
||||
)>,
|
||||
stop: &AtomicBool,
|
||||
status: &Mutex<RelayStatus>,
|
||||
local_auth_port: u16,
|
||||
local_world_port: Option<u16>,
|
||||
) -> Result<(), LinkError> {
|
||||
let mut ws = transport::connect(url, POLL)?;
|
||||
@@ -254,6 +306,7 @@ fn session(
|
||||
let (ws_out_tx, ws_out_rx) = channel::<TunnelMsg>();
|
||||
|
||||
let mut last_ping = Instant::now();
|
||||
let mut last_pong = Instant::now();
|
||||
|
||||
while !stop.load(Ordering::SeqCst) {
|
||||
// Drain pending allocation requests
|
||||
@@ -274,10 +327,14 @@ fn session(
|
||||
}
|
||||
|
||||
// Keepalive
|
||||
if last_pong.elapsed() > Duration::from_secs(90) {
|
||||
return Err(LinkError::Timeout);
|
||||
}
|
||||
if last_ping.elapsed() > Duration::from_secs(30) {
|
||||
let _ = ws.send(Message::Text(
|
||||
ws.send(Message::Text(
|
||||
serde_json::to_string(&TunnelMsg::Ping).unwrap().into(),
|
||||
));
|
||||
))
|
||||
.map_err(|error| LinkError::Io(error.to_string()))?;
|
||||
last_ping = Instant::now();
|
||||
}
|
||||
|
||||
@@ -318,7 +375,7 @@ fn session(
|
||||
} => {
|
||||
// Strictly predefined local targets: Auth (3724) or World (8085 or bound realm world port)
|
||||
let local_port = match target {
|
||||
RelayTarget::Auth => target.local_port(),
|
||||
RelayTarget::Auth => local_auth_port,
|
||||
RelayTarget::World => match local_world_port {
|
||||
Some(p) => p,
|
||||
None => {
|
||||
@@ -333,12 +390,21 @@ fn session(
|
||||
};
|
||||
match TcpStream::connect(("127.0.0.1", local_port)) {
|
||||
Ok(tcp) => {
|
||||
let tcp = Arc::new(tcp);
|
||||
let shutdown = tcp.clone();
|
||||
let (stream_tx, stream_rx) = channel::<TunnelMsg>();
|
||||
let ws_tx = ws_out_tx.clone();
|
||||
|
||||
// Spawn worker for this stream
|
||||
spawn_local_tcp_bridge(stream_id, tcp, stream_rx, ws_tx);
|
||||
streams.insert(stream_id, StreamWorker { tx: stream_tx });
|
||||
streams.insert(
|
||||
stream_id,
|
||||
StreamWorker {
|
||||
tx: stream_tx,
|
||||
reset_on_drop: true,
|
||||
shutdown,
|
||||
},
|
||||
);
|
||||
|
||||
// Reply ConnectOk
|
||||
let ok_msg = TunnelMsg::ConnectOk { stream_id };
|
||||
@@ -364,7 +430,8 @@ fn session(
|
||||
}
|
||||
}
|
||||
TunnelMsg::Close { stream_id } => {
|
||||
if let Some(w) = streams.remove(&stream_id) {
|
||||
if let Some(mut w) = streams.remove(&stream_id) {
|
||||
w.reset_on_drop = false;
|
||||
let _ = w.tx.send(TunnelMsg::Close { stream_id });
|
||||
}
|
||||
set_status(status, |s| s.active_streams = streams.len());
|
||||
@@ -375,7 +442,9 @@ fn session(
|
||||
}
|
||||
set_status(status, |s| s.active_streams = streams.len());
|
||||
}
|
||||
TunnelMsg::Pong => {}
|
||||
TunnelMsg::Pong => {
|
||||
last_pong = Instant::now();
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
@@ -392,19 +461,20 @@ fn session(
|
||||
|
||||
fn spawn_local_tcp_bridge(
|
||||
stream_id: u32,
|
||||
mut tcp: TcpStream,
|
||||
tcp: Arc<TcpStream>,
|
||||
from_tunnel: Receiver<TunnelMsg>,
|
||||
to_tunnel: Sender<TunnelMsg>,
|
||||
) {
|
||||
let _ = tcp.set_nonblocking(false);
|
||||
let _ = tcp.set_nodelay(true);
|
||||
let mut tcp_read = tcp.try_clone().expect("tcp clone");
|
||||
let tcp_read = tcp.clone();
|
||||
let to_tunnel_read = to_tunnel.clone();
|
||||
|
||||
// Local TCP read -> to tunnel
|
||||
std::thread::Builder::new()
|
||||
.name(format!("relay-stream-{stream_id}-read"))
|
||||
.spawn(move || {
|
||||
let mut tcp_read = tcp_read.as_ref();
|
||||
let mut buf = [0u8; 16384];
|
||||
loop {
|
||||
match tcp_read.read(&mut buf) {
|
||||
@@ -434,11 +504,12 @@ fn spawn_local_tcp_bridge(
|
||||
std::thread::Builder::new()
|
||||
.name(format!("relay-stream-{stream_id}-write"))
|
||||
.spawn(move || {
|
||||
let mut tcp_write = tcp.as_ref();
|
||||
while let Ok(msg) = from_tunnel.recv() {
|
||||
match msg {
|
||||
TunnelMsg::Data { chunk, .. } => {
|
||||
if let Ok(data) = base64::engine::general_purpose::STANDARD.decode(&chunk) {
|
||||
if tcp.write_all(&data).is_err() {
|
||||
if tcp_write.write_all(&data).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -457,3 +528,158 @@ fn spawn_local_tcp_bridge(
|
||||
})
|
||||
.ok();
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::net::TcpListener;
|
||||
use std::sync::atomic::AtomicUsize;
|
||||
|
||||
#[test]
|
||||
fn relay_reconnects_after_a_dropped_link_and_clears_its_error() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
let accepted = Arc::new(AtomicUsize::new(0));
|
||||
let seen = accepted.clone();
|
||||
let worker = std::thread::spawn(move || {
|
||||
for turn in 0..2 {
|
||||
let (tcp, _) = listener.accept().unwrap();
|
||||
tcp.set_read_timeout(Some(Duration::from_secs(15))).unwrap();
|
||||
let mut ws = tungstenite::accept(tcp).unwrap();
|
||||
ws.send(Message::Text(
|
||||
serde_json::to_string(&RelayChallenge {
|
||||
nonce: "relay-reconnect-test".into(),
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
let _: HostHello =
|
||||
serde_json::from_str(ws.read().unwrap().to_text().unwrap()).unwrap();
|
||||
ws.send(Message::Text(
|
||||
serde_json::to_string(&RelayWelcome {
|
||||
ok: true,
|
||||
message: String::new(),
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
seen.fetch_add(1, Ordering::SeqCst);
|
||||
if turn == 0 {
|
||||
continue;
|
||||
}
|
||||
while let Ok(message) = ws.read() {
|
||||
let Ok(text) = message.to_text() else {
|
||||
continue;
|
||||
};
|
||||
if let Ok(TunnelMsg::Allocate { request_id, .. }) = serde_json::from_str(text) {
|
||||
ws.send(Message::Text(
|
||||
serde_json::to_string(&TunnelMsg::AllocateOk {
|
||||
request_id,
|
||||
token: "test-only".into(),
|
||||
relay_host: "127.0.0.1".into(),
|
||||
auth_port: 41001,
|
||||
world_port: 41002,
|
||||
expires_at: i64::MAX,
|
||||
})
|
||||
.unwrap()
|
||||
.into(),
|
||||
))
|
||||
.unwrap();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
let link = RelayLink::start_with_local_ports(
|
||||
format!("ws://{addr}"),
|
||||
RealmId::new(),
|
||||
SigningKey::from_bytes(&[43; 32]),
|
||||
57324,
|
||||
Some(58085),
|
||||
);
|
||||
let until = Instant::now() + Duration::from_secs(20);
|
||||
while accepted.load(Ordering::SeqCst) < 2 || !link.status().connected {
|
||||
assert!(Instant::now() < until, "{:?}", link.status());
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
assert!(link.status().last_error.is_none());
|
||||
let allocation = link.allocate(&Uuid::now_v7(), None).unwrap();
|
||||
assert_eq!(
|
||||
(allocation.auth_port, allocation.world_port),
|
||||
(41001, 41002)
|
||||
);
|
||||
link.stop();
|
||||
worker.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_tunnel_exchanges_data_in_both_directions_before_reset() {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let tcp = Arc::new(TcpStream::connect(listener.local_addr().unwrap()).unwrap());
|
||||
let shutdown = tcp.clone();
|
||||
let (mut peer, _) = listener.accept().unwrap();
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let (tx, rx) = channel();
|
||||
let (out, replies) = channel();
|
||||
spawn_local_tcp_bridge(8, tcp, rx, out);
|
||||
let worker = StreamWorker {
|
||||
tx,
|
||||
reset_on_drop: true,
|
||||
shutdown,
|
||||
};
|
||||
worker
|
||||
.tx
|
||||
.send(TunnelMsg::Data {
|
||||
stream_id: 8,
|
||||
chunk: base64::engine::general_purpose::STANDARD.encode(b"request"),
|
||||
})
|
||||
.unwrap();
|
||||
let mut request = [0; 7];
|
||||
peer.read_exact(&mut request).unwrap();
|
||||
assert_eq!(&request, b"request");
|
||||
peer.write_all(b"response").unwrap();
|
||||
let mut response = Vec::new();
|
||||
while response.len() < 8 {
|
||||
match replies.recv_timeout(Duration::from_secs(3)).unwrap() {
|
||||
TunnelMsg::Data { chunk, .. } => response.extend(
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(chunk)
|
||||
.unwrap(),
|
||||
),
|
||||
other => panic!("Expected the local reply, got {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(response, b"response");
|
||||
drop(worker);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dropping_a_tunnel_closes_its_local_tcp_stream() {
|
||||
for _ in 0..32 {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let tcp = Arc::new(TcpStream::connect(listener.local_addr().unwrap()).unwrap());
|
||||
let shutdown = tcp.clone();
|
||||
let (mut peer, _) = listener.accept().unwrap();
|
||||
peer.set_read_timeout(Some(Duration::from_secs(3))).unwrap();
|
||||
let (tx, rx) = channel();
|
||||
let (out, _) = channel();
|
||||
spawn_local_tcp_bridge(7, tcp, rx, out);
|
||||
drop(StreamWorker {
|
||||
tx,
|
||||
reset_on_drop: true,
|
||||
shutdown,
|
||||
});
|
||||
match peer.read(&mut [0u8; 1]) {
|
||||
Ok(0) => {}
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
std::io::ErrorKind::ConnectionReset | std::io::ErrorKind::ConnectionAborted
|
||||
) => {}
|
||||
other => panic!("The abandoned relay stream must close, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,20 @@ use uuid::Uuid;
|
||||
use super::store::{AccountKind, ClaimState, ControlStore, HostTransfer, TransferState};
|
||||
use crate::{Error, Result};
|
||||
|
||||
mod portable_sync;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteSessionMessage {
|
||||
pub kind: app::SessionMessageKind,
|
||||
pub bytes: Vec<u8>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteSessionState {
|
||||
pub message: Option<RemoteSessionMessage>,
|
||||
pub closed: bool,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct RealmInfo {
|
||||
/// Create accounts for joining players (the default). When false the realm only links accounts that exist.
|
||||
@@ -64,6 +78,7 @@ pub struct ClaimExport {
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct RemoteImportParams {
|
||||
pub session_id: Uuid,
|
||||
pub character_id: Uuid,
|
||||
pub revision: u64,
|
||||
pub payload: Vec<u8>,
|
||||
@@ -89,6 +104,30 @@ pub enum PreflightVerdict {
|
||||
|
||||
/// The realm itself: its login database, its console and the portable engine.
|
||||
pub trait RealmBackend: Send + Sync {
|
||||
fn session_message(
|
||||
&self,
|
||||
_local_id: &str,
|
||||
_character: Uuid,
|
||||
_session: Uuid,
|
||||
_account: u32,
|
||||
) -> Result<RemoteSessionState> {
|
||||
Err(Error::Invalid(
|
||||
"Portable session synchronization is unavailable.".into(),
|
||||
))
|
||||
}
|
||||
fn session_ack(
|
||||
&self,
|
||||
_local_id: &str,
|
||||
_character: Uuid,
|
||||
_session: Uuid,
|
||||
_account: u32,
|
||||
_kind: app::SessionMessageKind,
|
||||
_bytes: &[u8],
|
||||
) -> Result<()> {
|
||||
Err(Error::Invalid(
|
||||
"Portable session synchronization is unavailable.".into(),
|
||||
))
|
||||
}
|
||||
fn info(&self, local_id: &str) -> Result<RealmInfo>;
|
||||
fn account_by_name(&self, local_id: &str, username: &str) -> Result<Option<AccountRow>>;
|
||||
fn account_by_id(&self, local_id: &str, id: u32) -> Result<Option<AccountRow>>;
|
||||
@@ -121,6 +160,9 @@ pub trait RealmBackend: Send + Sync {
|
||||
params: RemoteImportParams,
|
||||
) -> Result<RemoteImportOutcome>;
|
||||
/// Local world port for this realm. Must fail closed if the realm port cannot be resolved.
|
||||
fn auth_port(&self, _local_id: &str) -> Result<u16> {
|
||||
Ok(coa_control_proto::relay::LOCAL_AUTH_PORT)
|
||||
}
|
||||
fn world_port(&self, local_id: &str) -> Result<u16>;
|
||||
}
|
||||
|
||||
@@ -148,7 +190,7 @@ impl Session {
|
||||
}
|
||||
|
||||
const MAX_REQUESTS_PER_SESSION: u32 = 60;
|
||||
const MAX_TRANSFER_CHUNKS_PER_SESSION: u32 = 128;
|
||||
const MAX_TRANSFER_CHUNKS_PER_SESSION: u32 = 2048;
|
||||
const LINK_FAILURES: usize = 5;
|
||||
const LINK_WINDOW: Duration = Duration::from_secs(600);
|
||||
|
||||
@@ -181,6 +223,8 @@ fn err(code: AppError, message: &str) -> Response {
|
||||
|
||||
fn unavailable(what: &str, e: &Error) -> Response {
|
||||
tracing::warn!(error = %e, "{what}");
|
||||
#[cfg(test)]
|
||||
eprintln!("{what}: {e}");
|
||||
err(
|
||||
AppError::Unavailable,
|
||||
"The server could not do that right now.",
|
||||
@@ -222,6 +266,10 @@ impl HostService {
|
||||
self.realm_id
|
||||
}
|
||||
|
||||
pub fn local_auth_port(&self) -> Result<u16> {
|
||||
self.backend.auth_port(&self.local_id)
|
||||
}
|
||||
|
||||
pub fn local_world_port(&self) -> Result<u16> {
|
||||
self.backend.world_port(&self.local_id)
|
||||
}
|
||||
@@ -246,7 +294,12 @@ impl HostService {
|
||||
public_key: &str,
|
||||
request: Request,
|
||||
) -> Response {
|
||||
if matches!(request, Request::TransferChunk { .. }) {
|
||||
if matches!(
|
||||
request,
|
||||
Request::TransferChunk { .. }
|
||||
| Request::SessionRead { .. }
|
||||
| Request::SessionAck { .. }
|
||||
) {
|
||||
session.transfer_chunks += 1;
|
||||
if session.transfer_chunks > MAX_TRANSFER_CHUNKS_PER_SESSION {
|
||||
return err(
|
||||
@@ -325,6 +378,26 @@ impl HostService {
|
||||
content_hash,
|
||||
total_size,
|
||||
collections,
|
||||
false,
|
||||
),
|
||||
Request::OwnedTransferOffer {
|
||||
transfer_id,
|
||||
character_id,
|
||||
ruleset,
|
||||
canonical_revision,
|
||||
content_hash,
|
||||
total_size,
|
||||
collections,
|
||||
} => self.transfer_offer(
|
||||
player,
|
||||
transfer_id,
|
||||
character_id,
|
||||
ruleset,
|
||||
canonical_revision,
|
||||
content_hash,
|
||||
total_size,
|
||||
collections,
|
||||
true,
|
||||
),
|
||||
Request::TransferChunk {
|
||||
transfer_id,
|
||||
@@ -337,6 +410,32 @@ impl HostService {
|
||||
transfer_id,
|
||||
character_id,
|
||||
} => self.transfer_ack(player, transfer_id, character_id),
|
||||
Request::SessionPoll {
|
||||
character_id,
|
||||
session_id,
|
||||
} => self.session_poll(player, character_id, session_id),
|
||||
Request::SessionRead {
|
||||
character_id,
|
||||
session_id,
|
||||
message_hash,
|
||||
offset,
|
||||
} => self.session_read(player, character_id, session_id, &message_hash, offset),
|
||||
Request::SessionAck {
|
||||
character_id,
|
||||
session_id,
|
||||
message_hash,
|
||||
offset,
|
||||
total_size,
|
||||
data,
|
||||
} => self.session_ack_chunk(
|
||||
player,
|
||||
character_id,
|
||||
session_id,
|
||||
&message_hash,
|
||||
offset,
|
||||
total_size,
|
||||
&data,
|
||||
),
|
||||
Request::AllocateRelay { player_id } => {
|
||||
self.allocate_relay(session, player, &player_id)
|
||||
}
|
||||
@@ -742,6 +841,7 @@ impl HostService {
|
||||
content_hash: String,
|
||||
total_size: usize,
|
||||
collections: BTreeMap<String, Vec<u32>>,
|
||||
owned_session: bool,
|
||||
) -> Response {
|
||||
let Ok(_guard) = self.write.lock() else {
|
||||
return err(AppError::Unavailable, "Busy.");
|
||||
@@ -792,6 +892,41 @@ impl HostService {
|
||||
}
|
||||
}
|
||||
|
||||
// An exact committed retry returns its saved result before transient presence or compatibility checks.
|
||||
if let Ok(Some(existing)) = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| Error::Invalid("lock".into()))
|
||||
.and_then(|s| s.host_transfer_latest(&self.local_id, &character_id))
|
||||
{
|
||||
if existing.player_id != *player {
|
||||
return err(
|
||||
AppError::NotYours,
|
||||
"This character's transfer belongs to another player.",
|
||||
);
|
||||
}
|
||||
if existing.state != TransferState::Receiving
|
||||
&& (!owned_session || existing.transfer_id == transfer_id)
|
||||
&& existing.player_id == *player
|
||||
&& existing.ruleset == ruleset
|
||||
&& existing.total_size == total_size
|
||||
&& existing.collections == collections
|
||||
&& existing.revision == canonical_revision
|
||||
&& existing.content_hash == content_hash
|
||||
{
|
||||
if let (Some(guid), Some(sid)) = (existing.local_guid, existing.session_id) {
|
||||
return Response::TransferCommitted {
|
||||
transfer_id,
|
||||
character_id,
|
||||
local_guid: guid,
|
||||
session_id: sid,
|
||||
projected_level: existing.projected_level,
|
||||
notes: existing.notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Active session conflict: if account is online with active characters
|
||||
match self.backend.presence(&self.local_id, m.account_id) {
|
||||
Ok(p) if p.account_online && p.online_characters > 0 => {
|
||||
@@ -819,30 +954,6 @@ impl HostService {
|
||||
_ => {}
|
||||
}
|
||||
|
||||
// 4. Idempotency / already committed check
|
||||
if let Ok(Some(existing)) = self
|
||||
.store
|
||||
.lock()
|
||||
.map_err(|_| Error::Invalid("lock".into()))
|
||||
.and_then(|s| s.host_transfer_latest(&self.local_id, &character_id))
|
||||
{
|
||||
if existing.state != TransferState::Receiving
|
||||
&& existing.revision == canonical_revision
|
||||
&& existing.content_hash == content_hash
|
||||
{
|
||||
if let (Some(guid), Some(sid)) = (existing.local_guid, existing.session_id) {
|
||||
return Response::TransferCommitted {
|
||||
transfer_id,
|
||||
character_id,
|
||||
local_guid: guid,
|
||||
session_id: sid,
|
||||
projected_level: existing.projected_level,
|
||||
notes: existing.notes,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Resume check for this specific transfer_id
|
||||
if let Ok(Some(cur)) = self
|
||||
.store
|
||||
@@ -1099,6 +1210,7 @@ impl HostService {
|
||||
};
|
||||
|
||||
let params = RemoteImportParams {
|
||||
session_id: t.transfer_id,
|
||||
character_id: t.character_id,
|
||||
revision: t.revision,
|
||||
payload,
|
||||
@@ -2258,6 +2370,82 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn committed_transfer_retry_survives_presence_and_compatibility_changes() {
|
||||
let (realm, service, player, account) = claimed_setup();
|
||||
let character = Uuid::now_v7();
|
||||
let session_id = Uuid::now_v7();
|
||||
let (payload, hash) = test_snapshot(character, crate::portable::Ruleset::Coa);
|
||||
let transfer = HostTransfer {
|
||||
realm_local_id: service.local_id.clone(),
|
||||
transfer_id: Uuid::now_v7(),
|
||||
character_id: character,
|
||||
ruleset: "coa".into(),
|
||||
player_id: player,
|
||||
revision: 7,
|
||||
content_hash: hash.clone(),
|
||||
total_size: payload.len(),
|
||||
received_size: payload.len(),
|
||||
state: TransferState::Committed,
|
||||
local_guid: Some(3012),
|
||||
session_id: Some(session_id),
|
||||
projected_level: Some(60),
|
||||
notes: vec![],
|
||||
collections: BTreeMap::new(),
|
||||
};
|
||||
service
|
||||
.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_transfer_save(&transfer)
|
||||
.unwrap();
|
||||
realm.incompatible.lock().unwrap().insert(character);
|
||||
realm.online.lock().unwrap().insert(
|
||||
account,
|
||||
Presence {
|
||||
account_online: true,
|
||||
online_characters: 1,
|
||||
},
|
||||
);
|
||||
let offer = || Request::TransferOffer {
|
||||
transfer_id: Uuid::now_v7(),
|
||||
character_id: character,
|
||||
ruleset: "coa".into(),
|
||||
canonical_revision: 7,
|
||||
content_hash: hash.clone(),
|
||||
total_size: payload.len(),
|
||||
collections: BTreeMap::new(),
|
||||
};
|
||||
assert!(
|
||||
matches!(service.handle(&mut Session::default(), &player, "K", offer()),
|
||||
Response::TransferCommitted { local_guid: 3012, session_id: returned, .. } if returned == session_id)
|
||||
);
|
||||
let mut different = transfer;
|
||||
different.player_id = Uuid::now_v7();
|
||||
different.transfer_id = Uuid::now_v7();
|
||||
different.character_id = Uuid::now_v7();
|
||||
service
|
||||
.store
|
||||
.lock()
|
||||
.unwrap()
|
||||
.host_transfer_save(&different)
|
||||
.unwrap();
|
||||
let foreign_offer = Request::TransferOffer {
|
||||
transfer_id: Uuid::now_v7(),
|
||||
character_id: different.character_id,
|
||||
ruleset: "coa".into(),
|
||||
canonical_revision: 7,
|
||||
content_hash: hash.clone(),
|
||||
total_size: payload.len(),
|
||||
collections: BTreeMap::new(),
|
||||
};
|
||||
assert!(matches!(
|
||||
service.handle(&mut Session::default(), &player, "K", foreign_offer),
|
||||
Response::Error { .. }
|
||||
));
|
||||
assert!(realm.imported.lock().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrong_player_identity_cannot_overwrite_another_players_realm_copy() {
|
||||
let (_realm, s, p, _acc) = claimed_setup();
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
//! Authenticated, bounded transport for the existing Owner/Host session messages.
|
||||
use super::*;
|
||||
use base64::{engine::general_purpose::STANDARD as B64, Engine};
|
||||
use sha2::{Digest, Sha256};
|
||||
use std::io::Write;
|
||||
|
||||
fn digest(bytes: &[u8]) -> String {
|
||||
hex::encode(Sha256::digest(bytes))
|
||||
}
|
||||
|
||||
impl HostService {
|
||||
fn session_account(&self, player: &Uuid, character: Uuid) -> Option<u32> {
|
||||
let mapping = self.mapping(player)?;
|
||||
let transfer = self
|
||||
.store
|
||||
.lock()
|
||||
.ok()?
|
||||
.host_transfer_latest(&self.local_id, &character)
|
||||
.ok()??;
|
||||
(transfer.player_id == *player && transfer.state != TransferState::Receiving)
|
||||
.then_some(mapping.account_id)
|
||||
}
|
||||
|
||||
pub(super) fn session_poll(&self, player: &Uuid, character: Uuid, session: Uuid) -> Response {
|
||||
let Some(account) = self.session_account(player, character) else {
|
||||
return err(
|
||||
AppError::NotYours,
|
||||
"This portable session does not belong to this player.",
|
||||
);
|
||||
};
|
||||
match self
|
||||
.backend
|
||||
.session_message(&self.local_id, character, session, account)
|
||||
{
|
||||
Ok(RemoteSessionState {
|
||||
message: Some(message),
|
||||
..
|
||||
}) => Response::SessionPending {
|
||||
kind: message.kind,
|
||||
total_size: message.bytes.len(),
|
||||
content_hash: digest(&message.bytes),
|
||||
},
|
||||
Ok(state) => Response::SessionIdle {
|
||||
closed: state.closed,
|
||||
},
|
||||
Err(error) => unavailable("reading portable session", &error),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn session_read(
|
||||
&self,
|
||||
player: &Uuid,
|
||||
character: Uuid,
|
||||
session: Uuid,
|
||||
expected: &str,
|
||||
offset: usize,
|
||||
) -> Response {
|
||||
let Some(account) = self.session_account(player, character) else {
|
||||
return err(
|
||||
AppError::NotYours,
|
||||
"This portable session does not belong to this player.",
|
||||
);
|
||||
};
|
||||
let message =
|
||||
match self
|
||||
.backend
|
||||
.session_message(&self.local_id, character, session, account)
|
||||
{
|
||||
Ok(state) => match state.message {
|
||||
Some(message) => message,
|
||||
None => {
|
||||
return err(
|
||||
AppError::Conflict,
|
||||
"The portable session message changed; retry.",
|
||||
)
|
||||
}
|
||||
},
|
||||
Err(error) => return unavailable("reading portable session", &error),
|
||||
};
|
||||
if digest(&message.bytes) != expected || offset > message.bytes.len() {
|
||||
return err(
|
||||
AppError::Conflict,
|
||||
"The portable session message changed; retry.",
|
||||
);
|
||||
}
|
||||
let end = message.bytes.len().min(offset.saturating_add(24 * 1024));
|
||||
Response::SessionData {
|
||||
offset,
|
||||
data: B64.encode(&message.bytes[offset..end]),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn session_ack_chunk(
|
||||
&self,
|
||||
player: &Uuid,
|
||||
character: Uuid,
|
||||
session: Uuid,
|
||||
expected: &str,
|
||||
offset: usize,
|
||||
total_size: usize,
|
||||
data: &str,
|
||||
) -> Response {
|
||||
let Ok(_guard) = self.write.lock() else {
|
||||
return err(AppError::Unavailable, "Busy.");
|
||||
};
|
||||
let Some(account) = self.session_account(player, character) else {
|
||||
return err(
|
||||
AppError::NotYours,
|
||||
"This portable session does not belong to this player.",
|
||||
);
|
||||
};
|
||||
if expected.len() != 64
|
||||
|| !expected.bytes().all(|b| b.is_ascii_hexdigit())
|
||||
|| total_size == 0
|
||||
|| total_size > app::MAX_SESSION_BYTES
|
||||
|| offset > total_size
|
||||
|| data.len() > 48 * 1024
|
||||
{
|
||||
return err(AppError::Invalid, "Invalid acknowledgement bounds.");
|
||||
}
|
||||
let bytes = match B64.decode(data) {
|
||||
Ok(bytes) if bytes.len() <= total_size - offset => bytes,
|
||||
_ => return err(AppError::Invalid, "Invalid acknowledgement chunk."),
|
||||
};
|
||||
let directory = std::env::temp_dir()
|
||||
.join("coa-session-acks")
|
||||
.join(digest(self.local_id.as_bytes()));
|
||||
if let Err(error) = std::fs::create_dir_all(&directory) {
|
||||
return unavailable("preparing session acknowledgement", &Error::from(error));
|
||||
}
|
||||
let path = directory.join(format!("{character}-{session}.part"));
|
||||
let state = match self
|
||||
.backend
|
||||
.session_message(&self.local_id, character, session, account)
|
||||
{
|
||||
Ok(state) => state,
|
||||
Err(error) => return unavailable("reading portable session", &error),
|
||||
};
|
||||
let Some(message) = state.message else {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return Response::SessionAcked {
|
||||
received_size: total_size,
|
||||
complete: true,
|
||||
};
|
||||
};
|
||||
if digest(&message.bytes) != expected {
|
||||
return err(
|
||||
AppError::Conflict,
|
||||
"The portable session message changed; retry.",
|
||||
);
|
||||
}
|
||||
// Bind one partial file to the precise message and acknowledgement length.
|
||||
let mut header = expected.as_bytes().to_vec();
|
||||
header.extend_from_slice(&(total_size as u64).to_le_bytes());
|
||||
let write = (|| -> Result<usize> {
|
||||
if offset == 0 {
|
||||
let mut file = std::fs::File::create(&path)?;
|
||||
file.write_all(&header)?;
|
||||
}
|
||||
let current = std::fs::read(&path)?;
|
||||
if !current.starts_with(&header) {
|
||||
return Err(Error::Invalid(
|
||||
"The acknowledgement changed; restart it.".into(),
|
||||
));
|
||||
}
|
||||
let received = current.len() - header.len();
|
||||
if received != offset {
|
||||
return Ok(received);
|
||||
}
|
||||
let mut file = std::fs::OpenOptions::new().append(true).open(&path)?;
|
||||
file.write_all(&bytes)?;
|
||||
file.sync_all()?;
|
||||
Ok(received + bytes.len())
|
||||
})();
|
||||
let received = match write {
|
||||
Ok(received) => received,
|
||||
Err(error) => return unavailable("saving portable acknowledgement", &error),
|
||||
};
|
||||
if received != total_size {
|
||||
return Response::SessionAcked {
|
||||
received_size: received,
|
||||
complete: false,
|
||||
};
|
||||
}
|
||||
let complete = match std::fs::read(&path) {
|
||||
Ok(bytes) => bytes,
|
||||
Err(error) => {
|
||||
return unavailable("reading portable acknowledgement", &Error::from(error))
|
||||
}
|
||||
};
|
||||
match self.backend.session_ack(
|
||||
&self.local_id,
|
||||
character,
|
||||
session,
|
||||
account,
|
||||
message.kind,
|
||||
&complete[header.len()..],
|
||||
) {
|
||||
Ok(()) => {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
Response::SessionAcked {
|
||||
received_size: total_size,
|
||||
complete: true,
|
||||
}
|
||||
}
|
||||
Err(error) => unavailable("applying portable acknowledgement", &error),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,6 +72,13 @@ CREATE TABLE IF NOT EXISTS host_transfer(
|
||||
updated_at TEXT NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS host_transfer_char ON host_transfer(realm_local_id, character_id);
|
||||
CREATE TABLE IF NOT EXISTS player_remote_session(
|
||||
character_id TEXT PRIMARY KEY,
|
||||
realm_id TEXT NOT NULL,
|
||||
session_id TEXT NOT NULL,
|
||||
public_key TEXT NOT NULL,
|
||||
coordinator TEXT NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
fn now() -> String {
|
||||
@@ -177,6 +184,15 @@ pub struct PlayerRealm {
|
||||
pub kind: AccountKind,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PlayerRemoteSession {
|
||||
pub character_id: Uuid,
|
||||
pub realm_id: String,
|
||||
pub session_id: Uuid,
|
||||
pub public_key: String,
|
||||
pub coordinator: String,
|
||||
}
|
||||
|
||||
pub struct ControlStore {
|
||||
conn: Connection,
|
||||
}
|
||||
@@ -186,6 +202,66 @@ fn uuid(s: String) -> Result<Uuid> {
|
||||
}
|
||||
|
||||
impl ControlStore {
|
||||
pub fn player_remote_sessions(&self) -> Result<Vec<PlayerRemoteSession>> {
|
||||
let mut statement = self.conn.prepare("SELECT character_id, realm_id, session_id, public_key, coordinator FROM player_remote_session ORDER BY character_id").map_err(db)?;
|
||||
let rows = statement
|
||||
.query_map([], |row| {
|
||||
Ok((
|
||||
row.get::<_, String>(0)?,
|
||||
row.get::<_, String>(1)?,
|
||||
row.get::<_, String>(2)?,
|
||||
row.get::<_, String>(3)?,
|
||||
row.get::<_, String>(4)?,
|
||||
))
|
||||
})
|
||||
.map_err(db)?;
|
||||
rows.map(|row| {
|
||||
let (character, realm_id, session, public_key, coordinator) = row.map_err(db)?;
|
||||
Ok(PlayerRemoteSession {
|
||||
character_id: uuid(character)?,
|
||||
realm_id,
|
||||
session_id: uuid(session)?,
|
||||
public_key,
|
||||
coordinator,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn player_remote_session_set(&self, session: &PlayerRemoteSession) -> Result<()> {
|
||||
if self.player_remote_sessions()?.iter().any(|existing| {
|
||||
existing.character_id == session.character_id
|
||||
&& (existing.session_id != session.session_id
|
||||
|| existing.realm_id != session.realm_id
|
||||
|| existing.public_key != session.public_key)
|
||||
}) {
|
||||
return Err(Error::Invalid(
|
||||
"The character's previous remote session must finish saving first.".into(),
|
||||
));
|
||||
}
|
||||
self.conn.execute("INSERT INTO player_remote_session(character_id, realm_id, session_id, public_key, coordinator) VALUES(?1,?2,?3,?4,?5) ON CONFLICT(character_id) DO NOTHING", params![session.character_id.to_string(), session.realm_id, session.session_id.to_string(), session.public_key, session.coordinator]).map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn player_remote_session_advance(
|
||||
&self,
|
||||
character: Uuid,
|
||||
previous: Uuid,
|
||||
next: Uuid,
|
||||
) -> Result<()> {
|
||||
self.conn.execute("UPDATE player_remote_session SET session_id=?3 WHERE character_id=?1 AND session_id=?2", params![character.to_string(), previous.to_string(), next.to_string()]).map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn player_remote_session_remove(&self, character: Uuid, session: Uuid) -> Result<()> {
|
||||
self.conn
|
||||
.execute(
|
||||
"DELETE FROM player_remote_session WHERE character_id=?1 AND session_id=?2",
|
||||
params![character.to_string(), session.to_string()],
|
||||
)
|
||||
.map_err(db)?;
|
||||
Ok(())
|
||||
}
|
||||
pub fn open(dir: &Path) -> Result<Self> {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
let conn = Connection::open(dir.join("control.sqlite")).map_err(db)?;
|
||||
@@ -574,6 +650,43 @@ fn row_transfer(r: &rusqlite::Row<'_>) -> rusqlite::Result<Result<HostTransfer>>
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn remote_session_survives_restart_and_cannot_be_replaced() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let row = PlayerRemoteSession {
|
||||
character_id: Uuid::now_v7(),
|
||||
realm_id: "realm".into(),
|
||||
session_id: Uuid::now_v7(),
|
||||
public_key: "pinned-key".into(),
|
||||
coordinator: "wss://example.test/coord/v1/player".into(),
|
||||
};
|
||||
{
|
||||
let store = ControlStore::open(dir.path()).unwrap();
|
||||
store.player_remote_session_set(&row).unwrap();
|
||||
store.player_remote_session_set(&row).unwrap();
|
||||
let mut other = row.clone();
|
||||
other.session_id = Uuid::now_v7();
|
||||
assert!(store.player_remote_session_set(&other).is_err());
|
||||
other = row.clone();
|
||||
other.public_key = "changed-key".into();
|
||||
assert!(store.player_remote_session_set(&other).is_err());
|
||||
}
|
||||
let store = ControlStore::open(dir.path()).unwrap();
|
||||
let recovered = store.player_remote_sessions().unwrap().pop().unwrap();
|
||||
assert_eq!(
|
||||
(recovered.session_id, recovered.public_key),
|
||||
(row.session_id, row.public_key)
|
||||
);
|
||||
store
|
||||
.player_remote_session_remove(row.character_id, Uuid::now_v7())
|
||||
.unwrap();
|
||||
assert_eq!(store.player_remote_sessions().unwrap().len(), 1);
|
||||
store
|
||||
.player_remote_session_remove(row.character_id, row.session_id)
|
||||
.unwrap();
|
||||
assert!(store.player_remote_sessions().unwrap().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_player_owns_one_account_per_realm_and_an_account_one_player() {
|
||||
let s = ControlStore::open_in_memory().unwrap();
|
||||
|
||||
@@ -24,17 +24,25 @@ pub struct CrashItem {
|
||||
|
||||
/// Lists recent crash reports from `Core/Crashes`, sorted newest first.
|
||||
pub fn list(root: &Path, limit: usize) -> Result<Vec<CrashItem>> {
|
||||
let dir = root.join("Core/Crashes");
|
||||
let mut roots = vec![root.to_path_buf()];
|
||||
roots.extend(crate::multiworld::Tree::ALL.map(|tree| crate::multiworld::tree_root(root, tree)));
|
||||
let mut files: Vec<(PathBuf, SystemTime)> = Vec::new();
|
||||
if let Ok(entries) = fs::read_dir(&dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("txt") {
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
files.push((path, mtime));
|
||||
for world_root in roots {
|
||||
let entries = match fs::read_dir(world_root.join("Core/Crashes")) {
|
||||
Ok(entries) => Some(entries),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
if let Some(entries) = entries {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
if path.is_file() && path.extension().and_then(|e| e.to_str()) == Some("txt") {
|
||||
let mtime = entry
|
||||
.metadata()
|
||||
.and_then(|m| m.modified())
|
||||
.unwrap_or(SystemTime::UNIX_EPOCH);
|
||||
files.push((path, mtime));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -43,8 +51,12 @@ pub fn list(root: &Path, limit: usize) -> Result<Vec<CrashItem>> {
|
||||
|
||||
let mut items = Vec::new();
|
||||
for (path, mtime) in files {
|
||||
if let Ok(content) = fs::read_to_string(&path) {
|
||||
let item = parse_crash_content(&path, mtime, &content);
|
||||
if let Ok(bytes) = fs::read(&path) {
|
||||
let content = String::from_utf8_lossy(&bytes);
|
||||
let mut item = parse_crash_content(&path, mtime, &content);
|
||||
if let Ok(relative) = path.strip_prefix(root) {
|
||||
item.id = relative.to_string_lossy().replace('\\', "/");
|
||||
}
|
||||
items.push(item);
|
||||
}
|
||||
}
|
||||
@@ -237,6 +249,49 @@ fn explain(
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn discovers_native_ansi_reports_and_ignores_dump_files() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
assert!(list(root.path(), 10).unwrap().is_empty());
|
||||
fs::create_dir_all(root.path().join("Core/Crashes")).unwrap();
|
||||
fs::write(
|
||||
root.path().join("Core/Crashes/report.txt"),
|
||||
b"Exception code: C0000005 \xff\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
fs::write(root.path().join("Core/Crashes/report.dmp"), b"binary").unwrap();
|
||||
let reports = list(root.path(), 10).unwrap();
|
||||
assert_eq!(reports.len(), 1);
|
||||
assert_eq!(reports[0].exception_code.as_deref(), Some("C0000005"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discovers_reports_from_all_worlds_without_filename_collisions() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
for relative in [
|
||||
"Core/Crashes",
|
||||
".realms/secondary/Core/Crashes",
|
||||
".realms/reborn/Core/Crashes",
|
||||
] {
|
||||
fs::create_dir_all(root.path().join(relative)).unwrap();
|
||||
fs::write(
|
||||
root.path().join(relative).join("same.txt"),
|
||||
b"Exception code: C0000005",
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
let reports = list(root.path(), 10).unwrap();
|
||||
assert_eq!(reports.len(), 3);
|
||||
assert_eq!(
|
||||
reports
|
||||
.iter()
|
||||
.map(|r| &r.id)
|
||||
.collect::<std::collections::HashSet<_>>()
|
||||
.len(),
|
||||
3
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_assertion_report() {
|
||||
let report = r#"Revision: AzerothCore rev. df5bcdf26092 2026-10-02 22:08:42 +0500 (coa-bots branch) (Win64, RelWithDebInfo, Static)
|
||||
|
||||
@@ -14,12 +14,14 @@ use crate::download::Cancel;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::fsx;
|
||||
|
||||
pub const CLIENT_VERSION: &str = "1.8.3";
|
||||
pub const CLIENT_VERSION: &str = "1.9.2";
|
||||
pub const MANIFEST_FILENAME: &str = "custom_races_patch_manifest.txt";
|
||||
pub const ARCHIVES: &[(&str, &str)] = &[
|
||||
("CoA-Custom-1.8.3-Client-1.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.8.3/CoA-Custom-1.8.3-Client-1.zip"),
|
||||
("CoA-Custom-1.8.3-Client-2.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.8.3/CoA-Custom-1.8.3-Client-2.zip"),
|
||||
("CoA-Custom-1.8.3-Client-3.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.8.3/CoA-Custom-1.8.3-Client-3.zip"),
|
||||
("CoA-Custom-1.9.2-Client-1.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.9.2/CoA-Custom-1.9.2-Client-1.zip"),
|
||||
("CoA-Custom-1.9.2-Client-2.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.9.2/CoA-Custom-1.9.2-Client-2.zip"),
|
||||
("CoA-Custom-1.9.2-Client-3.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.9.2/CoA-Custom-1.9.2-Client-3.zip"),
|
||||
("CoA-Custom-1.9.2-Client-4.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.9.2/CoA-Custom-1.9.2-Client-4.zip"),
|
||||
("CoA-Custom-1.9.2-Client-5.zip", "https://github.com/ilusixn/azerothcore-wotlk-coa/releases/download/coa-custom-1.9.2/CoA-Custom-1.9.2-Client-5.zip"),
|
||||
];
|
||||
|
||||
pub const RACE_MPQS: &[&str] = &[
|
||||
@@ -53,9 +55,15 @@ pub const RACE_MPQS: &[&str] = &[
|
||||
"patch-ZZX.MPQ",
|
||||
"patch-ZZZC.MPQ",
|
||||
"patch-ZZZD.MPQ",
|
||||
"patch-ZZZA.MPQ",
|
||||
"patch-ZZZE.MPQ",
|
||||
"patch-ZZZF.MPQ",
|
||||
"patch-ZZZZ.MPQ",
|
||||
];
|
||||
|
||||
pub const RACE_ADDONS: &[&str] = &[
|
||||
"CoAQuestSafe/CoAQuestSafe.lua",
|
||||
"CoAQuestSafe/CoAQuestSafe.toc",
|
||||
"CoAWC2Cursor/CoAWC2Cursor.lua",
|
||||
"CoAWC2Cursor/CoAWC2Cursor.toc",
|
||||
"CoAHideArmor/CoAHideArmor.lua",
|
||||
@@ -103,6 +111,13 @@ pub fn playerbots_conf_paths(root: &Path) -> Vec<PathBuf> {
|
||||
/// Checks whether playerbots are configured to allow custom races.
|
||||
/// Returns true if AiPlayerbot.ExcludedBotRaces exists and does not exclude general custom races.
|
||||
pub fn is_bot_custom_races_allowed(root: &Path) -> Option<bool> {
|
||||
if let Ok(bytes) = fs::read(root.join("Core/configs/modules/coa_custom_races.conf")) {
|
||||
if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) {
|
||||
if let Some(value) = conf.get("CoACustomRaces.BotsEnable") {
|
||||
return Some(matches!(value.trim().trim_matches('"'), "1" | "true"));
|
||||
}
|
||||
}
|
||||
}
|
||||
for path in playerbots_conf_paths(root) {
|
||||
if let Ok(bytes) = fs::read(&path) {
|
||||
if let Ok(conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) {
|
||||
@@ -134,6 +149,15 @@ pub fn sync_bot_races_config(root: &Path, allow_custom_races: bool) -> Result<()
|
||||
for path in playerbots_conf_paths(root) {
|
||||
if let Ok(bytes) = fs::read(&path) {
|
||||
if let Ok(mut conf) = crate::config::parser::ConfFile::parse_bytes(&bytes) {
|
||||
if let Some(value) = conf.get("AiPlayerbot.ExcludedBotRaces") {
|
||||
let clean = value.trim().trim_matches(['"', '\'']);
|
||||
let owned = String::from_utf8_lossy(&bytes)
|
||||
.contains("CoA Custom: races new random bots never get")
|
||||
&& matches!(clean, EXCLUDED_BOT_RACES_RECOMMENDED | EXCLUDED_BOT_RACES_EXPERIMENTAL);
|
||||
if !owned {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
conf.set(
|
||||
"AiPlayerbot.ExcludedBotRaces",
|
||||
&format!("\"{excluded}\""),
|
||||
@@ -186,6 +210,10 @@ fn patch_files(client: &Path) -> Result<Vec<PatchFile>> {
|
||||
include_str!("custom_races_hashes_1_7.json")
|
||||
} else if manifest.lines().any(|line| line == "# CoA Custom 1.8") {
|
||||
include_str!("custom_races_hashes_1_8.json")
|
||||
} else if manifest.lines().any(|line| line == "# CoA Custom 1.8.3") {
|
||||
include_str!("custom_races_hashes_1_8_3.json")
|
||||
} else if manifest.lines().any(|line| line == "# CoA Custom 1.9.1") {
|
||||
include_str!("custom_races_hashes_1_9_1.json")
|
||||
} else {
|
||||
include_str!("custom_races_hashes.json")
|
||||
};
|
||||
@@ -200,11 +228,6 @@ fn patch_files(client: &Path) -> Result<Vec<PatchFile>> {
|
||||
}
|
||||
Some(format!("Data/{name}"))
|
||||
} else if let Some(name) = line.strip_prefix("ROOT:") {
|
||||
if name.contains(['/', '\\']) {
|
||||
return Err(Error::Invalid(
|
||||
"Invalid custom races patch manifest.".into(),
|
||||
));
|
||||
}
|
||||
Some(name.to_string())
|
||||
} else {
|
||||
line.strip_prefix("ADDON:")
|
||||
@@ -624,16 +647,37 @@ pub fn uninstall_client_patches(client: &Path) -> Result<()> {
|
||||
tx.finish()
|
||||
}
|
||||
|
||||
fn stage_root_asset(staging: &Path, relative: &str, reader: &mut impl Read) -> Result<String> {
|
||||
let dest = fsx::safe_join(staging, relative)?;
|
||||
fs::create_dir_all(
|
||||
dest.parent()
|
||||
.ok_or_else(|| Error::Invalid("Invalid client asset path.".into()))?,
|
||||
)?;
|
||||
let mut output = File::create(&dest)?;
|
||||
io::copy(reader, &mut output)?;
|
||||
let disabled = PathBuf::from(format!("{}.disabled", dest.display()));
|
||||
if disabled.is_file() {
|
||||
fs::remove_file(disabled)?;
|
||||
}
|
||||
Ok(relative.to_string())
|
||||
}
|
||||
|
||||
fn verify_archive(path: &Path, filename: &str) -> Result<()> {
|
||||
let expected = match filename {
|
||||
"CoA-Custom-1.8.3-Client-1.zip" => {
|
||||
"CoA-Custom-1.9.2-Client-1.zip" => {
|
||||
"a9dd51ed2d90fb048d549602f40f5429d56e2df0df5606a38781b358fa369568"
|
||||
}
|
||||
"CoA-Custom-1.8.3-Client-2.zip" => {
|
||||
"CoA-Custom-1.9.2-Client-2.zip" => {
|
||||
"ab084a55509ccbcc2977a06dd0c01fa879973a1892baaacaeb3ff52f3dfc750e"
|
||||
}
|
||||
"CoA-Custom-1.8.3-Client-3.zip" => {
|
||||
"270c7b306624bf9e5c3fe577e3bd058601221df223dd9e449ff7c25de70b9461"
|
||||
"CoA-Custom-1.9.2-Client-3.zip" => {
|
||||
"907e267f0ee7038807e626c3c1eca5e12db6c88b747137a23c5fb52eaa1a358e"
|
||||
}
|
||||
"CoA-Custom-1.9.2-Client-4.zip" => {
|
||||
"e0bd87172af989fa50a8e2b08ba4321c72b6265047482f051a1db3b80d55bcaa"
|
||||
}
|
||||
"CoA-Custom-1.9.2-Client-5.zip" => {
|
||||
"73364ad1b58c6342641a848a3b20a835472834f600416606be1470386774ca78"
|
||||
}
|
||||
_ => return Err(Error::Invalid("Unknown custom races archive.".into())),
|
||||
};
|
||||
@@ -867,22 +911,8 @@ pub fn install_client_patches(
|
||||
let mut out = File::create(&dest)?;
|
||||
io::copy(&mut zfile, &mut out)?;
|
||||
installed_roots.push(relative);
|
||||
} else if zname_norm.contains("files/client_root/") {
|
||||
let base = Path::new(&zname_norm)
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_default();
|
||||
|
||||
if !base.is_empty() {
|
||||
let dest = staging.path().join(&base);
|
||||
let mut out = File::create(&dest)?;
|
||||
io::copy(&mut zfile, &mut out)?;
|
||||
let disabled = staging.path().join(format!("{base}.disabled"));
|
||||
if disabled.is_file() {
|
||||
fs::remove_file(disabled)?;
|
||||
}
|
||||
installed_roots.push(base);
|
||||
}
|
||||
} else if let Some(relative) = zname_norm.split("files/client_root/").nth(1) {
|
||||
installed_roots.push(stage_root_asset(staging.path(), relative, &mut zfile)?);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1048,6 +1078,56 @@ mod tests {
|
||||
fs::remove_dir(link).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nested_root_manifest_remains_valid_and_rejects_traversal() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let relative = "Data/Retail/Assets/EquipmentFitting/60-0.bin";
|
||||
fs::write(
|
||||
temp.path().join(MANIFEST_FILENAME),
|
||||
format!("# CoA Custom {CLIENT_VERSION}\nROOT:{relative}\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(patch_files(temp.path()).unwrap()[0].path, relative);
|
||||
fs::write(temp.path().join(MANIFEST_FILENAME), "ROOT:../outside.bin\n").unwrap();
|
||||
assert!(patch_files(temp.path()).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_split_release_retains_its_original_hash_inventory() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::write(temp.path().join(MANIFEST_FILENAME), "# CoA Custom 1.8.3\n").unwrap();
|
||||
let old: std::collections::BTreeMap<String, String> =
|
||||
serde_json::from_str(include_str!("custom_races_hashes_1_8_3.json")).unwrap();
|
||||
let files = patch_files(temp.path()).unwrap();
|
||||
assert_eq!(files.len(), old.len());
|
||||
assert!(files
|
||||
.iter()
|
||||
.all(|file| old.get(&file.path) == Some(&file.sha256)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn previous_191_release_keeps_its_hashes_and_does_not_claim_new_addons() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
fs::write(temp.path().join(MANIFEST_FILENAME), "# CoA Custom 1.9.1\n").unwrap();
|
||||
let old: std::collections::BTreeMap<String, String> =
|
||||
serde_json::from_str(include_str!("custom_races_hashes_1_9_1.json")).unwrap();
|
||||
let files = patch_files(temp.path()).unwrap();
|
||||
assert_eq!(files.len(), old.len());
|
||||
assert!(files.iter().all(|file| old.get(&file.path) == Some(&file.sha256)));
|
||||
assert!(!files.iter().any(|file| file.path.contains("CoAQuestSafe")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn root_assets_preserve_nested_paths_and_reject_traversal() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let relative = "Data/Retail/Assets/EquipmentFitting/60-0.bin";
|
||||
let result = stage_root_asset(temp.path(), relative, &mut &b"fitting"[..]).unwrap();
|
||||
assert_eq!(result, relative);
|
||||
assert_eq!(fs::read(temp.path().join(relative)).unwrap(), b"fitting");
|
||||
assert!(!temp.path().join("60-0.bin").exists());
|
||||
assert!(stage_root_asset(temp.path(), "../outside.bin", &mut &b"bad"[..]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(windows)]
|
||||
fn shared_addons_junction_supports_install_switch_and_rollback() {
|
||||
@@ -1557,7 +1637,7 @@ mod tests {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let conf = temp.path().join("Core/configs/modules/playerbots.conf");
|
||||
fs::create_dir_all(conf.parent().unwrap()).unwrap();
|
||||
fs::write(&conf, "AiPlayerbot.ExcludedBotRaces = \"\"\n").unwrap();
|
||||
fs::write(&conf, "AiPlayerbot.Enabled = 1\n").unwrap();
|
||||
sync_bot_races_config(temp.path(), false).unwrap();
|
||||
assert_eq!(is_bot_custom_races_allowed(temp.path()), Some(false));
|
||||
let parsed =
|
||||
@@ -1575,4 +1655,19 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bot_race_sync_preserves_user_and_crashing_exclusions() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let conf = temp.path().join("Core/configs/modules/playerbots.conf");
|
||||
fs::create_dir_all(conf.parent().unwrap()).unwrap();
|
||||
for value in ["", "1,82,97", EXCLUDED_BOT_RACES_RECOMMENDED] {
|
||||
let original = format!("AiPlayerbot.ExcludedBotRaces = \"{value}\"\nAiPlayerbot.CrashingBotRaces = \"72,77,97\"\n");
|
||||
fs::write(&conf, &original).unwrap();
|
||||
sync_bot_races_config(temp.path(), true).unwrap();
|
||||
assert_eq!(fs::read_to_string(&conf).unwrap(), original);
|
||||
sync_bot_races_config(temp.path(), false).unwrap();
|
||||
assert_eq!(fs::read_to_string(&conf).unwrap(), original);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,20 @@
|
||||
{
|
||||
"Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547",
|
||||
"Data/Retail/Assets/EquipmentFitting/1068-1.bin": "332d2c38b037a118f262bbb3096a01ebfb33c5347e96bbfb64157437b300281b",
|
||||
"Data/Retail/Assets/EquipmentFitting/60-0.bin": "ffbf6efc9ea09b0c442f048c5bc7b4c5975258045cfddedd9a77a93363312b5e",
|
||||
"Data/Retail/Assets/EquipmentFitting/60-1.bin": "36ceed2a55756f3322e018938809acea5124351161405058ef27d100173778da",
|
||||
"Data/Retail/Assets/EquipmentFitting/61-0.bin": "3b302a4055d1e795c451b02e38dfe8007527ee1bc5f926ebcd6ef17f433cd232",
|
||||
"Data/Retail/Assets/EquipmentFitting/61-1.bin": "c42df399ed99be3c7ce1b5fdb95a873711011713a4cd867bc226a14b1ce0e8ca",
|
||||
"Data/Retail/Assets/EquipmentFitting/65-0.bin": "8ce3f884ae197d14dff0f955c2cf05abb716742c8aff79f7af1cec5cf7225f04",
|
||||
"Data/Retail/Assets/EquipmentFitting/68-0.bin": "f1d878f0f943cbdb8025af8daac4a38c1d1e159bd961d9d76f225931c11648ac",
|
||||
"Data/Retail/Assets/EquipmentFitting/68-1.bin": "2ccbae634384200340c0cc3dca36a7a014a8ddad110598e4470ff1b266576465",
|
||||
"Data/Retail/Assets/EquipmentFitting/73-0.bin": "5530882678f677ad47987ce906e851338a35f7d9737e3f9d829938027b8af67f",
|
||||
"Data/Retail/Assets/EquipmentFitting/74-0.bin": "53a7be86b3bfdd6995406c706df3ca091484206d8141cebb39113c714500384a",
|
||||
"Data/Retail/Assets/EquipmentFitting/74-1.bin": "e688c55a355231978d42480a003c26d379460f5931c2469c119ecb073ea153e1",
|
||||
"Data/Retail/Assets/EquipmentFitting/75-0.bin": "289abc71c6df0e031a64d62a40e658eaefd37e45373a062ca52f08fbe803500c",
|
||||
"Data/Retail/Assets/EquipmentFitting/76-0.bin": "63a493e70b91f59ff49f1942d959df986062bd267fe2ba589f344963df771021",
|
||||
"Data/Retail/Assets/EquipmentFitting/77-0.bin": "b1366ce0dc4f31cb8c8c9705be2da565df4b905224af04b6b88f8c8e9c87e3c9",
|
||||
"Data/Retail/Assets/EquipmentFitting/78-0.bin": "7b0031fb12ab19020005bdccacca2dd3991349c36949a58b035992585b4134db",
|
||||
"Data/patch-T.MPQ": "fe597138ab7f56881b5f4f27d6dc9d81bb278b483ca0d91b7207ffd4a5ae7bfd",
|
||||
"Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283",
|
||||
"Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c",
|
||||
@@ -22,28 +37,48 @@
|
||||
"Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb",
|
||||
"Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3",
|
||||
"Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8",
|
||||
"Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c",
|
||||
"Data/patch-ZZU.MPQ": "8ee1839415b56d46c6eff7eb4dfbaf6fbafc4c6b29b39380d69f4d827d02a99e",
|
||||
"Data/patch-ZZV.MPQ": "8d5e61d37008abf70478e5d5f46ec7afb0490b9a643323bd75a1c70cadd63290",
|
||||
"Data/patch-ZZW.MPQ": "3689c485f8ee8fccaa6dbae1d073bc9231229f9a040f79a272d80a87486d0c33",
|
||||
"Data/patch-ZZT.MPQ": "e60dab63fe5650fdf5f0846ea6d4136219293bcfdad46d9b796697faeff9ab4c",
|
||||
"Data/patch-ZZU.MPQ": "0311de8f034eae7dec1bd2c92b1fe6c741cbaf53da62c7609cd12b6c37908e47",
|
||||
"Data/patch-ZZV.MPQ": "3b04008905e7185d165827d68f7ca228dda8272816364f7d71874ecc83b64f0e",
|
||||
"Data/patch-ZZW.MPQ": "3bbbc72901fd2d662113036485c380701e693f5a39ba9479af306bf2fa2030bd",
|
||||
"Data/patch-ZZX.MPQ": "4c12608089cfe356f7c8882d66db90239ed3ec8ddd980d91d7a3a488680f3c88",
|
||||
"Data/patch-ZZZ.MPQ": "566aaadf501af42e29a828b1bc2edbd36d436288fb309fe423bd74b9619bf593",
|
||||
"Data/patch-ZZZA.MPQ": "57ccbdc0a66000767554ffd194088df6d602ddd8341a141bca8201e7d5435889",
|
||||
"Data/patch-ZZZC.MPQ": "691a53f6325c2615a64ced0802f18bc4d96e99c62aaa606fc930f9406ee74cdf",
|
||||
"Data/patch-ZZZD.MPQ": "e4ce9d8b363eb609d0d6d92627eb53454fa2e03e4dbc1795b3573a22b7d009c8",
|
||||
"Data/patch-ZZZD.MPQ": "ca893cf8129f6fd442a63f0697a8ddea88546e3ce71bde713be2cc832b892bc9",
|
||||
"Data/patch-ZZZE.MPQ": "2e245ee0fe3b913087eed399062fda5843d98b04afac38024f7c64ac3bf43e18",
|
||||
"Data/patch-ZZZF.MPQ": "40eac695cfcafb598b8376f3b9a61ecc037e4d445b87279cfe387a91edad32c3",
|
||||
"Data/patch-ZZZZ.MPQ": "860c990db3b4185fdc36e1a001d3ca3507a68253a4506793a115f86eb0892a15",
|
||||
"EsteriaAppearance.bin": "90d9cfaeaf6de10f014f1d1610ea10453ec9b240255759aea99513cac488a3ec",
|
||||
"EsteriaAppearance.dll": "196a82f24dd287653e2b2fc4d12bfc796f8ee3f6687ca8308905a5ab528a51a9",
|
||||
"EsteriaAppearance.dll": "88e268d3be9eca3b0644f543d823f4bdcc04a9700a6e8ed73fd2a1260ad191af",
|
||||
"EsteriaAppearanceGeometry.bin": "19de5b4b4f79778ea5316659efc2f66c6edb6b5149c46efa47c46bfa4ec6904c",
|
||||
"EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba",
|
||||
"EsteriaBanshee.bin": "d3fe66caa6a0ecba96bddb07ac1cbdb915694e87fc1608da0fdabecdf9ffea4d",
|
||||
"EsteriaBansheeTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaCentaur.bin": "d63f0b7d21cee67d234d134e832fe6d55c398f2b63f9f6c191dcfa9671637185",
|
||||
"EsteriaCentaurTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f",
|
||||
"EsteriaDrakonid.bin": "95440ee9c065ef20bb7fed6158d20623d68745291cedc2726fc746ff54601f95",
|
||||
"EsteriaDrakonidCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||
"EsteriaDrakonidTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02",
|
||||
"EsteriaEthereal.bin": "ada3b7588511a847dd5154d91e3041e9f7f193998b06bfede5e0afc32570b48c",
|
||||
"EsteriaEtherealTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaFaceless.bin": "1c5a1d7a6b1db6984a8e41bc4804e9f7c50d65dcdbb8c0a9d1a9e8af8c91bd8f",
|
||||
"EsteriaFacelessTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2",
|
||||
"EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f",
|
||||
"EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229",
|
||||
"EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938",
|
||||
"EsteriaHozen.bin": "739bc3775388976f5c09bcbf3a98b62d804dff78d545e25a41d07dd99b5fddc5",
|
||||
"EsteriaHozenTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaKvItems.bin": "fc5f9d86114234b38dbb0e14a6a5ec16249eaa92a73411a7fc371a950549dcce",
|
||||
"EsteriaKvSkins.bin": "60b2b899f5f83137368514928f92f3b04c0fae058f534c487ddbe913ee4bec18",
|
||||
"EsteriaKyrian.bin": "9329147f08805ff4003f9e062fea7c6ea8fdd8c4d6a40de63a6d228ebe1b7c9a",
|
||||
"EsteriaKyrianTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaMantid.bin": "71536dee6c2abd0b0e13f33bc73cad7e6fb1b0fc1f6912a95c55d57bc7ee1787",
|
||||
"EsteriaMantidTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3",
|
||||
"EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3",
|
||||
"EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa",
|
||||
@@ -51,18 +86,26 @@
|
||||
"EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606",
|
||||
"EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a",
|
||||
"EsteriaSlime.bin": "491e942ceb6abda83c0249afebae7c9c43af3897b35e45fa33d6f9177ecbc177",
|
||||
"EsteriaSlimeTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaTarasek.bin": "f718444d5ec36f7aff018883486ae1981151fe6e01f5b7781d68d74e9fadfa47",
|
||||
"EsteriaTarasekTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb",
|
||||
"EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e",
|
||||
"EsteriaThinHuman.bin": "bc12e5ca2c087ca0a8642ff6cd8e78fe2e1f85928518b12c19b4245a8320585d",
|
||||
"EsteriaThinHumanTextures.bin": "27488a7494fa9733b53118d1d0f4acef50a9e19004f81f9c8a614b10f4c3fd5e",
|
||||
"EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387",
|
||||
"EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71",
|
||||
"EsteriaVenthyr.bin": "5bcf7804363e59b3c922830d06de17bbd348b9967307f39b7d72bc09f58814eb",
|
||||
"EsteriaVenthyrTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaVorkai.bin": "d6d2088c7e867b5db437e9afc2d323c22d830c6f84a3598415eedcaa27e2461f",
|
||||
"EsteriaVorkaiTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c",
|
||||
"EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296",
|
||||
"EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4",
|
||||
"EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e",
|
||||
"Interface/AddOns/CoAHideArmor/CoAHideArmor.lua": "8c1c7403524fc9bd56c0809b4101b141da700ba163701209bf1420f2ef106aa7",
|
||||
"Interface/AddOns/CoAHideArmor/CoAHideArmor.toc": "123580d92fd5ceee258e96296f42c359b7e83cdff151af49c50c6c4e2059677c",
|
||||
"Interface/AddOns/CoAQuestSafe/CoAQuestSafe.lua": "062de55c91228971553f482fd2c8753f4752993b4e8e49d4ded04fa18b98bde7",
|
||||
"Interface/AddOns/CoAQuestSafe/CoAQuestSafe.toc": "890bb9d1087e236f4823603f5df3ab0e316dbb65c28feebbda08fb0ab53a408f",
|
||||
"Interface/AddOns/CoAWC2Cursor/CoAWC2Cursor.lua": "1cf9537ff5f10b77250fea4407aea1008da690afe102ed6ed2dac21f79638cfa",
|
||||
"Interface/AddOns/CoAWC2Cursor/CoAWC2Cursor.toc": "48ce695dce712b3643ebe13a77e318d623c964acc69479b8fc8cc675099801bf",
|
||||
"Interface/AddOns/CoA_TalentReset/CoA_TalentReset.lua": "827d756d5948ef918fafaf8b869290b3a1faa4e4cc21b19b512a00d4509f3d41",
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547",
|
||||
"Data/patch-T.MPQ": "fe597138ab7f56881b5f4f27d6dc9d81bb278b483ca0d91b7207ffd4a5ae7bfd",
|
||||
"Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283",
|
||||
"Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c",
|
||||
"Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87",
|
||||
"Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf",
|
||||
"Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484",
|
||||
"Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248",
|
||||
"Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934",
|
||||
"Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a",
|
||||
"Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d",
|
||||
"Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f",
|
||||
"Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39",
|
||||
"Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c",
|
||||
"Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617",
|
||||
"Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0",
|
||||
"Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de",
|
||||
"Data/patch-ZK.MPQ": "444d0ecb72c1e990509d53e3cfa5b7634c816733533e8a97e5d7f5653ee32cce",
|
||||
"Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61",
|
||||
"Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020",
|
||||
"Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb",
|
||||
"Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3",
|
||||
"Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8",
|
||||
"Data/patch-ZZT.MPQ": "aef3bbe4951333d62fb55224484724fbaa9c35773a19944ffb9935e250cfb59c",
|
||||
"Data/patch-ZZU.MPQ": "8ee1839415b56d46c6eff7eb4dfbaf6fbafc4c6b29b39380d69f4d827d02a99e",
|
||||
"Data/patch-ZZV.MPQ": "8d5e61d37008abf70478e5d5f46ec7afb0490b9a643323bd75a1c70cadd63290",
|
||||
"Data/patch-ZZW.MPQ": "3689c485f8ee8fccaa6dbae1d073bc9231229f9a040f79a272d80a87486d0c33",
|
||||
"Data/patch-ZZX.MPQ": "4c12608089cfe356f7c8882d66db90239ed3ec8ddd980d91d7a3a488680f3c88",
|
||||
"Data/patch-ZZZ.MPQ": "566aaadf501af42e29a828b1bc2edbd36d436288fb309fe423bd74b9619bf593",
|
||||
"Data/patch-ZZZC.MPQ": "691a53f6325c2615a64ced0802f18bc4d96e99c62aaa606fc930f9406ee74cdf",
|
||||
"Data/patch-ZZZD.MPQ": "e4ce9d8b363eb609d0d6d92627eb53454fa2e03e4dbc1795b3573a22b7d009c8",
|
||||
"EsteriaAppearance.bin": "90d9cfaeaf6de10f014f1d1610ea10453ec9b240255759aea99513cac488a3ec",
|
||||
"EsteriaAppearance.dll": "196a82f24dd287653e2b2fc4d12bfc796f8ee3f6687ca8308905a5ab528a51a9",
|
||||
"EsteriaAppearanceGeometry.bin": "19de5b4b4f79778ea5316659efc2f66c6edb6b5149c46efa47c46bfa4ec6904c",
|
||||
"EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba",
|
||||
"EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f",
|
||||
"EsteriaDrakonid.bin": "95440ee9c065ef20bb7fed6158d20623d68745291cedc2726fc746ff54601f95",
|
||||
"EsteriaDrakonidCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||
"EsteriaDrakonidTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02",
|
||||
"EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2",
|
||||
"EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f",
|
||||
"EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229",
|
||||
"EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938",
|
||||
"EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3",
|
||||
"EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3",
|
||||
"EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa",
|
||||
"EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||
"EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606",
|
||||
"EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a",
|
||||
"EsteriaTarasek.bin": "f718444d5ec36f7aff018883486ae1981151fe6e01f5b7781d68d74e9fadfa47",
|
||||
"EsteriaTarasekTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaThinHuman.bin": "75794d192ea0c228b3da7bf7d0bdff1119f79d3f9bfef6650cd5e1a2b5d106eb",
|
||||
"EsteriaThinHumanTextures.bin": "02aeb4c05893d06125b8b58fdb7c2a56fe3d66a7d094706a44b43cb503d1731e",
|
||||
"EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387",
|
||||
"EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71",
|
||||
"EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c",
|
||||
"EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296",
|
||||
"EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4",
|
||||
"EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e",
|
||||
"Interface/AddOns/CoAHideArmor/CoAHideArmor.lua": "8c1c7403524fc9bd56c0809b4101b141da700ba163701209bf1420f2ef106aa7",
|
||||
"Interface/AddOns/CoAHideArmor/CoAHideArmor.toc": "123580d92fd5ceee258e96296f42c359b7e83cdff151af49c50c6c4e2059677c",
|
||||
"Interface/AddOns/CoAWC2Cursor/CoAWC2Cursor.lua": "1cf9537ff5f10b77250fea4407aea1008da690afe102ed6ed2dac21f79638cfa",
|
||||
"Interface/AddOns/CoAWC2Cursor/CoAWC2Cursor.toc": "48ce695dce712b3643ebe13a77e318d623c964acc69479b8fc8cc675099801bf",
|
||||
"Interface/AddOns/CoA_TalentReset/CoA_TalentReset.lua": "827d756d5948ef918fafaf8b869290b3a1faa4e4cc21b19b512a00d4509f3d41",
|
||||
"Interface/AddOns/CoA_TalentReset/CoA_TalentReset.toc": "16298c04af68e68d6445e7a11f42aee525dd4145f85d2771ff434fb7c6e95a85",
|
||||
"dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c",
|
||||
"wc2_human.cur": "f609736e29596bb386ef1597b157d211e01a86d32c2a929e256c581c37d71791",
|
||||
"wc2_orc.cur": "baa512c9b9045762ba35b6e0d35b8f79c54b7649f35cc9c0e0cc8c168a1e5a97"
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"Ascension.exe": "e2c347019d554a59639e5978fd68e50e906973ab313c6573cc56ca82e8e3c547",
|
||||
"Data/Retail/Assets/EquipmentFitting/1068-1.bin": "332d2c38b037a118f262bbb3096a01ebfb33c5347e96bbfb64157437b300281b",
|
||||
"Data/Retail/Assets/EquipmentFitting/60-0.bin": "ffbf6efc9ea09b0c442f048c5bc7b4c5975258045cfddedd9a77a93363312b5e",
|
||||
"Data/Retail/Assets/EquipmentFitting/60-1.bin": "36ceed2a55756f3322e018938809acea5124351161405058ef27d100173778da",
|
||||
"Data/Retail/Assets/EquipmentFitting/61-0.bin": "3b302a4055d1e795c451b02e38dfe8007527ee1bc5f926ebcd6ef17f433cd232",
|
||||
"Data/Retail/Assets/EquipmentFitting/61-1.bin": "c42df399ed99be3c7ce1b5fdb95a873711011713a4cd867bc226a14b1ce0e8ca",
|
||||
"Data/Retail/Assets/EquipmentFitting/65-0.bin": "8ce3f884ae197d14dff0f955c2cf05abb716742c8aff79f7af1cec5cf7225f04",
|
||||
"Data/Retail/Assets/EquipmentFitting/68-0.bin": "f1d878f0f943cbdb8025af8daac4a38c1d1e159bd961d9d76f225931c11648ac",
|
||||
"Data/Retail/Assets/EquipmentFitting/68-1.bin": "2ccbae634384200340c0cc3dca36a7a014a8ddad110598e4470ff1b266576465",
|
||||
"Data/Retail/Assets/EquipmentFitting/73-0.bin": "5530882678f677ad47987ce906e851338a35f7d9737e3f9d829938027b8af67f",
|
||||
"Data/Retail/Assets/EquipmentFitting/74-0.bin": "53a7be86b3bfdd6995406c706df3ca091484206d8141cebb39113c714500384a",
|
||||
"Data/Retail/Assets/EquipmentFitting/74-1.bin": "e688c55a355231978d42480a003c26d379460f5931c2469c119ecb073ea153e1",
|
||||
"Data/Retail/Assets/EquipmentFitting/75-0.bin": "289abc71c6df0e031a64d62a40e658eaefd37e45373a062ca52f08fbe803500c",
|
||||
"Data/Retail/Assets/EquipmentFitting/76-0.bin": "63a493e70b91f59ff49f1942d959df986062bd267fe2ba589f344963df771021",
|
||||
"Data/Retail/Assets/EquipmentFitting/77-0.bin": "b1366ce0dc4f31cb8c8c9705be2da565df4b905224af04b6b88f8c8e9c87e3c9",
|
||||
"Data/Retail/Assets/EquipmentFitting/78-0.bin": "7b0031fb12ab19020005bdccacca2dd3991349c36949a58b035992585b4134db",
|
||||
"Data/patch-T.MPQ": "fe597138ab7f56881b5f4f27d6dc9d81bb278b483ca0d91b7207ffd4a5ae7bfd",
|
||||
"Data/patch-ZE1.MPQ": "69d79dbbea54ec0312c37770debc79c7bc9ce2e21eedef99c03cd8d911955283",
|
||||
"Data/patch-ZE2.MPQ": "1a41cae77ad730e9cf69e8d63fd12ae3a579418bb550d351fff469573a97ac3c",
|
||||
"Data/patch-ZE3.MPQ": "b587202c21b6f580682b601b7c2e905fbfa766276e74733db55c1bddbffb7f87",
|
||||
"Data/patch-ZE5.MPQ": "476b5a1fcf69f64fdb281e3b445362b936d016066ccd12f897acfcf200d6c0cf",
|
||||
"Data/patch-ZE6.MPQ": "762592c9ed950b4a7b6c7d85d45050b30fa3f35d53e348ee1e1516fdbb7e5484",
|
||||
"Data/patch-ZE7.MPQ": "de54b0189d242763d2872223419a259b6b8a4b380c24b50a2ef9c6fa44466248",
|
||||
"Data/patch-ZE8.MPQ": "1bc5691fb84a84f3e7a0a70baaa9192a97b4ec5906ee37c85838ffe08adef934",
|
||||
"Data/patch-ZE9.MPQ": "d49c94463e9548e76378ce2c0454d8f204f0745c8c0de32b6dce7f0b0fd67c4a",
|
||||
"Data/patch-ZEA.MPQ": "78fd10ec5262ae2417eba9700d2172553bbc1e5cfb0b597aada8f1753ca46d9d",
|
||||
"Data/patch-ZES.MPQ": "a2d9348f917f745c7aa25b2eed96efa312f7b2d2f62cb86a4f2eaabc2dfa895f",
|
||||
"Data/patch-ZG.MPQ": "48979e1fda775f8deb47bffa48e0a468595d4c71286fee518f51dc5801c5ca39",
|
||||
"Data/patch-ZH.MPQ": "5e1d2fe287053abba703d7af381e10096c6525ba477ffed3971c2fc4f93e919c",
|
||||
"Data/patch-ZHA.MPQ": "0fc6ebeafe1d14192dc64bafc90bde3c49230df1297f98bf65900a3bcf953617",
|
||||
"Data/patch-ZHM.MPQ": "317d3ae00f0bd674b358af07544976acb7804aa6d8b19d1788ad96030e8c9bf0",
|
||||
"Data/patch-ZJ.MPQ": "f821e99add86ef68d108c5ec466328c97677d3ed52f7944e200f3fe38c13b4de",
|
||||
"Data/patch-ZK.MPQ": "444d0ecb72c1e990509d53e3cfa5b7634c816733533e8a97e5d7f5653ee32cce",
|
||||
"Data/patch-ZM.MPQ": "424ab1978986e854551633983a94bd15beba6328aabea8c02f98eaa32f6f0b61",
|
||||
"Data/patch-ZVU.MPQ": "c679b85a2e97344395756b54d2cf6abefe4e80e74944cb55b6e573ad17b7d020",
|
||||
"Data/patch-ZZE.MPQ": "4b1e289e12c82644cb78f50170e6b5fd53918eb2de834f9e0cebddd81873a8bb",
|
||||
"Data/patch-ZZR.MPQ": "5a7575e592dc0c8a6913e0d946bbe06fa0ca2e4d817ef1f8548d3d13142bcbd3",
|
||||
"Data/patch-ZZS.MPQ": "533fd69d5784d83e872a5a28d27301dc9ab5d0cb925f8b2116926854e4a0a5b8",
|
||||
"Data/patch-ZZT.MPQ": "e60dab63fe5650fdf5f0846ea6d4136219293bcfdad46d9b796697faeff9ab4c",
|
||||
"Data/patch-ZZU.MPQ": "0311de8f034eae7dec1bd2c92b1fe6c741cbaf53da62c7609cd12b6c37908e47",
|
||||
"Data/patch-ZZV.MPQ": "3b04008905e7185d165827d68f7ca228dda8272816364f7d71874ecc83b64f0e",
|
||||
"Data/patch-ZZW.MPQ": "3bbbc72901fd2d662113036485c380701e693f5a39ba9479af306bf2fa2030bd",
|
||||
"Data/patch-ZZX.MPQ": "4c12608089cfe356f7c8882d66db90239ed3ec8ddd980d91d7a3a488680f3c88",
|
||||
"Data/patch-ZZZ.MPQ": "566aaadf501af42e29a828b1bc2edbd36d436288fb309fe423bd74b9619bf593",
|
||||
"Data/patch-ZZZA.MPQ": "57ccbdc0a66000767554ffd194088df6d602ddd8341a141bca8201e7d5435889",
|
||||
"Data/patch-ZZZC.MPQ": "691a53f6325c2615a64ced0802f18bc4d96e99c62aaa606fc930f9406ee74cdf",
|
||||
"Data/patch-ZZZD.MPQ": "ca893cf8129f6fd442a63f0697a8ddea88546e3ce71bde713be2cc832b892bc9",
|
||||
"Data/patch-ZZZE.MPQ": "2e245ee0fe3b913087eed399062fda5843d98b04afac38024f7c64ac3bf43e18",
|
||||
"Data/patch-ZZZF.MPQ": "0e0dd0b46e5308756af779803259e2ba6c99920a01b62a01505b268c20a15bbe",
|
||||
"Data/patch-ZZZZ.MPQ": "ae27e2f203755c8a74f21cabf8d3ebafd076b53452b1eb9d894fa1ce9837d0ea",
|
||||
"EsteriaAppearance.bin": "90d9cfaeaf6de10f014f1d1610ea10453ec9b240255759aea99513cac488a3ec",
|
||||
"EsteriaAppearance.dll": "88e268d3be9eca3b0644f543d823f4bdcc04a9700a6e8ed73fd2a1260ad191af",
|
||||
"EsteriaAppearanceGeometry.bin": "19de5b4b4f79778ea5316659efc2f66c6edb6b5149c46efa47c46bfa4ec6904c",
|
||||
"EsteriaAppearanceMaterials.bin": "f72f027f54a55c05aa0f76b457b97b484fe0e1aedc0e068cc0e0ca53e1878cba",
|
||||
"EsteriaBanshee.bin": "d3fe66caa6a0ecba96bddb07ac1cbdb915694e87fc1608da0fdabecdf9ffea4d",
|
||||
"EsteriaBansheeTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaCentaur.bin": "d63f0b7d21cee67d234d134e832fe6d55c398f2b63f9f6c191dcfa9671637185",
|
||||
"EsteriaCentaurTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaCosmeticWings.bin": "57352c28459d073a29b3014d33b70f3b3756afa37fa9583ef6b30d503422ac3f",
|
||||
"EsteriaDrakonid.bin": "95440ee9c065ef20bb7fed6158d20623d68745291cedc2726fc746ff54601f95",
|
||||
"EsteriaDrakonidCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||
"EsteriaDrakonidTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaEarthen.bin": "8a0db93ba2d5c756b44ed84f2f7bc7c15eef13be6a7d87baf1383c6c0c624e02",
|
||||
"EsteriaEthereal.bin": "ada3b7588511a847dd5154d91e3041e9f7f193998b06bfede5e0afc32570b48c",
|
||||
"EsteriaEtherealTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaFaceless.bin": "1c5a1d7a6b1db6984a8e41bc4804e9f7c50d65dcdbb8c0a9d1a9e8af8c91bd8f",
|
||||
"EsteriaFacelessTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaGnoll.bin": "cfacc8e681bb8e54a4f01ee9c12a91bf0bd23a23527bb548d4dc21ec1b33eea2",
|
||||
"EsteriaGnollTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaHaranir.bin": "59f31d00e372fd2646e1cf0d9d3d5d671b31edd60ff7b862f8d1ce7e53ba1f2f",
|
||||
"EsteriaHaranirTextures.bin": "99948ca570be2d376f0c67e3b8088739b6fa9f9d2c73985e19f68df77689a229",
|
||||
"EsteriaHighmountain.bin": "e83349252ad2687ac23fcfc5623db7401cf32e5faf47a5a7be20c2f94b014938",
|
||||
"EsteriaHozen.bin": "739bc3775388976f5c09bcbf3a98b62d804dff78d545e25a41d07dd99b5fddc5",
|
||||
"EsteriaHozenTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaKvItems.bin": "fc5f9d86114234b38dbb0e14a6a5ec16249eaa92a73411a7fc371a950549dcce",
|
||||
"EsteriaKvSkins.bin": "60b2b899f5f83137368514928f92f3b04c0fae058f534c487ddbe913ee4bec18",
|
||||
"EsteriaKyrian.bin": "9329147f08805ff4003f9e062fea7c6ea8fdd8c4d6a40de63a6d228ebe1b7c9a",
|
||||
"EsteriaKyrianTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaMantid.bin": "71536dee6c2abd0b0e13f33bc73cad7e6fb1b0fc1f6912a95c55d57bc7ee1787",
|
||||
"EsteriaMantidTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaNaga.bin": "e87547cff5d09500c294f8b06935d134cdd0bbb962e294a72de09404505bd8b3",
|
||||
"EsteriaNagaTextures.bin": "57ec560f99c1d48ac1e8f6a797c156513b99e7d349e3071afe405f812d159ae3",
|
||||
"EsteriaSaberon.bin": "7735c9325c807c5125760d87f323d6b7d4162fb542c1d8b5f435ea2d6b9a4daa",
|
||||
"EsteriaSaberonCoverage.bin": "9cdc227f08329e1245d2b864a996e7a772c96ecdbe5d81925f4d60cdbe4fa11e",
|
||||
"EsteriaSaberonTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaSethrak.bin": "bd14857a8c11fdf8027128763293701586b2102f9a6539d33a727014789fd606",
|
||||
"EsteriaSethrakTextures.bin": "d2b2cf58087a391f00198008fe871edd400ddf28d54a3a05674df74c5198528a",
|
||||
"EsteriaSlime.bin": "491e942ceb6abda83c0249afebae7c9c43af3897b35e45fa33d6f9177ecbc177",
|
||||
"EsteriaSlimeTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaTarasek.bin": "f718444d5ec36f7aff018883486ae1981151fe6e01f5b7781d68d74e9fadfa47",
|
||||
"EsteriaTarasekTextures.bin": "1ba9133a75dad291d98a068b2dcde01f31a897a1764cd7f3e2c316c288a8f605",
|
||||
"EsteriaThinHuman.bin": "bc12e5ca2c087ca0a8642ff6cd8e78fe2e1f85928518b12c19b4245a8320585d",
|
||||
"EsteriaThinHumanTextures.bin": "27488a7494fa9733b53118d1d0f4acef50a9e19004f81f9c8a614b10f4c3fd5e",
|
||||
"EsteriaTuskarr.bin": "ecbe7ef490ba67eca029a57639192db61238522618491549766fe38529914387",
|
||||
"EsteriaTuskarrTextures.bin": "e2116c2c83adea079126fd5da765d20da24f044d4f161c4124e4368cb3d6bc71",
|
||||
"EsteriaVenthyr.bin": "5bcf7804363e59b3c922830d06de17bbd348b9967307f39b7d72bc09f58814eb",
|
||||
"EsteriaVenthyrTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaVorkai.bin": "d6d2088c7e867b5db437e9afc2d323c22d830c6f84a3598415eedcaa27e2461f",
|
||||
"EsteriaVorkaiTextures.bin": "837774377ca055e6a5c79c1a4cfd8151e7c1f7ce2c16990b00fd445281137433",
|
||||
"EsteriaVrykul.bin": "39e2b7b4c67997dbbd34bae840bd606a59290f7f2fc82565a0ba00090335415c",
|
||||
"EsteriaVrykulTextures.bin": "46098750a0e235724c6e9cc8d9d4e26550aa47bef95fd5f0f82efe5e945bf296",
|
||||
"EsteriaVulpera.bin": "c5434993b9af4a17d881965e4735cd2ac379e39c7142aac9844adff9528562d4",
|
||||
"EsteriaVulperaTextures.bin": "1ab844e74684aced2b069f1b91cd563aade45e05bd5e23f866a6c0c301cb098e",
|
||||
"Interface/AddOns/CoAHideArmor/CoAHideArmor.lua": "8c1c7403524fc9bd56c0809b4101b141da700ba163701209bf1420f2ef106aa7",
|
||||
"Interface/AddOns/CoAHideArmor/CoAHideArmor.toc": "123580d92fd5ceee258e96296f42c359b7e83cdff151af49c50c6c4e2059677c",
|
||||
"Interface/AddOns/CoAWC2Cursor/CoAWC2Cursor.lua": "1cf9537ff5f10b77250fea4407aea1008da690afe102ed6ed2dac21f79638cfa",
|
||||
"Interface/AddOns/CoAWC2Cursor/CoAWC2Cursor.toc": "48ce695dce712b3643ebe13a77e318d623c964acc69479b8fc8cc675099801bf",
|
||||
"Interface/AddOns/CoA_TalentReset/CoA_TalentReset.lua": "827d756d5948ef918fafaf8b869290b3a1faa4e4cc21b19b512a00d4509f3d41",
|
||||
"Interface/AddOns/CoA_TalentReset/CoA_TalentReset.toc": "16298c04af68e68d6445e7a11f42aee525dd4145f85d2771ff434fb7c6e95a85",
|
||||
"dinput8.dll": "f8de78606c241fb3911dce22faf68f5b9f9885b142b183c1e2c09ddc8f7e380c",
|
||||
"wc2_human.cur": "f609736e29596bb386ef1597b157d211e01a86d32c2a929e256c581c37d71791",
|
||||
"wc2_orc.cur": "baa512c9b9045762ba35b6e0d35b8f79c54b7649f35cc9c0e0cc8c168a1e5a97"
|
||||
}
|
||||
+153
-2
@@ -5,9 +5,10 @@
|
||||
|
||||
use std::fmt;
|
||||
use std::fs::File;
|
||||
use std::io::{BufReader, BufWriter, Read, Write};
|
||||
use std::io::{BufReader, BufWriter, Read, Seek, SeekFrom, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
@@ -48,6 +49,7 @@ pub struct Db {
|
||||
realm: crate::realms::Mode,
|
||||
/// Name of the database container of a Docker installation; the client tools then run inside it.
|
||||
container: Option<String>,
|
||||
read_deadline: Option<Instant>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
@@ -67,6 +69,42 @@ fn no_window(cmd: &mut Command) {
|
||||
let _ = cmd;
|
||||
}
|
||||
|
||||
fn bounded_client_output(command: &mut Command, input: &[u8], deadline: Instant) -> Result<std::process::Output> {
|
||||
let timeout = || Error::Invalid("The database inspection timed out. The Manager stopped its client process; the database server was not stopped. Retry the operation or export diagnostics if this continues.".into());
|
||||
if Instant::now() >= deadline {
|
||||
return Err(timeout());
|
||||
}
|
||||
let mut stdin = tempfile::tempfile()?;
|
||||
stdin.write_all(input)?;
|
||||
stdin.seek(SeekFrom::Start(0))?;
|
||||
let mut stdout = tempfile::tempfile()?;
|
||||
let mut stderr = tempfile::tempfile()?;
|
||||
command.stdin(stdin).stdout(stdout.try_clone()?).stderr(stderr.try_clone()?);
|
||||
let mut child = command.spawn()?;
|
||||
let status = loop {
|
||||
match child.try_wait() {
|
||||
Ok(Some(status)) => break status,
|
||||
Ok(None) if Instant::now() < deadline => std::thread::sleep(Duration::from_millis(25)),
|
||||
result => {
|
||||
child.kill().map_err(|error| Error::Invalid(format!(
|
||||
"The database inspection timed out, but its client could not be stopped: {error}"
|
||||
)))?;
|
||||
let _ = child.wait();
|
||||
return match result {
|
||||
Err(error) => Err(error.into()),
|
||||
_ => Err(timeout()),
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
stdout.seek(SeekFrom::Start(0))?;
|
||||
stderr.seek(SeekFrom::Start(0))?;
|
||||
let mut output = std::process::Output { status, stdout: Vec::new(), stderr: Vec::new() };
|
||||
stdout.read_to_end(&mut output.stdout)?;
|
||||
stderr.read_to_end(&mut output.stderr)?;
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
/// Schema names of the three game databases.
|
||||
pub const SCHEMAS: [(&str, &str); 3] = [
|
||||
("characters", "acore_characters"),
|
||||
@@ -135,6 +173,7 @@ impl Db {
|
||||
password: Secret(password),
|
||||
realm: crate::realms::state(root)?.active,
|
||||
container: None,
|
||||
read_deadline: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -155,6 +194,7 @@ impl Db {
|
||||
password: Secret(password),
|
||||
realm: crate::realms::state(root)?.active,
|
||||
container: Some(container),
|
||||
read_deadline: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -174,6 +214,7 @@ impl Db {
|
||||
password: Secret(password.to_string()),
|
||||
realm,
|
||||
container: None,
|
||||
read_deadline: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -292,7 +333,13 @@ impl Db {
|
||||
pub fn ping(&self) -> bool {
|
||||
let mut c = self.command("mysqladmin.exe");
|
||||
c.arg("ping").arg("--connect-timeout=3");
|
||||
c.output().map(|o| o.status.success()).unwrap_or(false)
|
||||
bounded_client_output(&mut c, b"", Instant::now() + Duration::from_secs(5))
|
||||
.map(|o| o.status.success()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub(crate) fn with_read_deadline(mut self, deadline: Instant) -> Self {
|
||||
self.read_deadline = Some(deadline);
|
||||
self
|
||||
}
|
||||
|
||||
/// Run SQL text (statement(s) supplied on stdin) and return the raw output.
|
||||
@@ -303,6 +350,13 @@ impl Db {
|
||||
"--default-character-set=utf8mb4",
|
||||
"--max-allowed-packet=128M",
|
||||
]);
|
||||
if let Some(deadline) = self.read_deadline {
|
||||
let out = bounded_client_output(&mut c, route_sql(sql, self.realm).as_bytes(), deadline)?;
|
||||
if !out.status.success() {
|
||||
return Err(self.fail(&out.stderr));
|
||||
}
|
||||
return Ok(String::from_utf8_lossy(&out.stdout).trim().to_string());
|
||||
}
|
||||
c.stdin(Stdio::piped());
|
||||
let mut child = c.spawn()?;
|
||||
child
|
||||
@@ -604,6 +658,103 @@ mod realm_tests {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
fn client_fixture(mode: &str) -> std::process::Command {
|
||||
let mut command = std::process::Command::new(std::env::current_exe().unwrap());
|
||||
command.args(["--exact", "db::tests::bounded_client_fixture", "--nocapture"])
|
||||
.env("COA_DATABASE_CLIENT_TEST", mode);
|
||||
super::no_window(&mut command);
|
||||
command
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_client_fixture() {
|
||||
match std::env::var("COA_DATABASE_CLIENT_TEST").as_deref() {
|
||||
Ok("hang") => std::thread::sleep(std::time::Duration::from_secs(60)),
|
||||
Ok("output") => {
|
||||
use std::io::{Read, Write};
|
||||
let mut input = Vec::new();
|
||||
std::io::stdin().read_to_end(&mut input).unwrap();
|
||||
std::io::stdout().write_all(&input).unwrap();
|
||||
std::io::stderr().write_all(&vec![b'e'; 256 * 1024]).unwrap();
|
||||
}
|
||||
Ok("fail") => std::process::exit(17),
|
||||
_ => (),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_client_captures_large_output_without_pipe_deadlock() {
|
||||
let data = vec![b'x'; 256 * 1024];
|
||||
let out = super::bounded_client_output(&mut client_fixture("output"), &data,
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(10)).unwrap();
|
||||
assert!(out.status.success());
|
||||
assert!(out.stdout.windows(data.len()).any(|part| part == data));
|
||||
assert_eq!(out.stderr, vec![b'e'; 256 * 1024]);
|
||||
let out = super::bounded_client_output(&mut client_fixture("fail"), b"",
|
||||
std::time::Instant::now() + std::time::Duration::from_secs(10)).unwrap();
|
||||
assert_eq!(out.status.code(), Some(17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounded_client_timeout_releases_the_update_operation_lock() {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let result = (|| {
|
||||
let _lock = crate::update::operation_lock_for(folder.path(), "checking updates", false)?;
|
||||
super::bounded_client_output(&mut client_fixture("hang"), b"",
|
||||
start + std::time::Duration::from_millis(250))
|
||||
})();
|
||||
assert!(result.unwrap_err().to_string().contains("inspection timed out"));
|
||||
assert!(start.elapsed() < std::time::Duration::from_secs(5));
|
||||
crate::update::operation_lock(folder.path()).unwrap();
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
#[test]
|
||||
#[ignore = "requires COA_MYSQL_TEST_BIN; starts a disposable MySQL server"]
|
||||
fn real_mysql_inspection_timeout_preserves_server_and_releases_lock() {
|
||||
use std::os::windows::process::CommandExt;
|
||||
struct Server(std::process::Child);
|
||||
impl Drop for Server {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.0.kill();
|
||||
let _ = self.0.wait();
|
||||
}
|
||||
}
|
||||
let bin = std::path::PathBuf::from(std::env::var("COA_MYSQL_TEST_BIN").unwrap());
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let data = folder.path().join("data");
|
||||
let initialized = std::process::Command::new(bin.join("mysqld.exe"))
|
||||
.args(["--no-defaults", "--initialize-insecure"])
|
||||
.arg(format!("--datadir={}", data.display()))
|
||||
.creation_flags(0x0800_0000).output().unwrap();
|
||||
assert!(initialized.status.success(), "{}", String::from_utf8_lossy(&initialized.stderr));
|
||||
let socket = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = socket.local_addr().unwrap().port();
|
||||
drop(socket);
|
||||
let mut server = Server(std::process::Command::new(bin.join("mysqld.exe"))
|
||||
.args(["--no-defaults", "--bind-address=127.0.0.1", "--mysqlx=0", "--skip-log-bin"])
|
||||
.arg(format!("--datadir={}", data.display())).arg(format!("--port={port}"))
|
||||
.arg(format!("--log-error={}", folder.path().join("mysql.log").display()))
|
||||
.stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null())
|
||||
.creation_flags(0x0800_0000).spawn().unwrap());
|
||||
let db = super::Db::with_tools(bin, port, "root", "", crate::realms::Mode::Coa);
|
||||
let ready = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while !db.ping() {
|
||||
assert!(std::time::Instant::now() < ready);
|
||||
std::thread::sleep(std::time::Duration::from_millis(100));
|
||||
}
|
||||
let result = (|| {
|
||||
let _lock = crate::update::operation_lock_for(folder.path(), "checking updates", false)?;
|
||||
db.clone().with_read_deadline(std::time::Instant::now() + std::time::Duration::from_millis(500))
|
||||
.query("SELECT SLEEP(60);")
|
||||
})();
|
||||
assert!(result.unwrap_err().to_string().contains("inspection timed out"));
|
||||
assert!(server.0.try_wait().unwrap().is_none());
|
||||
assert_eq!(db.query("SELECT 42;").unwrap(), "42");
|
||||
crate::update::operation_lock(folder.path()).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reborn_sql_routes_only_database_identifiers() {
|
||||
use crate::realms::Mode;
|
||||
|
||||
@@ -31,36 +31,17 @@ impl Verb {
|
||||
}
|
||||
}
|
||||
|
||||
fn timeout(self, root: &Path) -> Duration {
|
||||
fn timeout(self, _root: &Path) -> Duration {
|
||||
match self {
|
||||
Verb::StartAll | Verb::StartWorld if squid_enabled(root) => Duration::from_secs(2700),
|
||||
Verb::StartAll | Verb::StartWorld => Duration::from_secs(420),
|
||||
Verb::StartMysql => Duration::from_secs(120),
|
||||
// Includes database startup, optional bot provisioning and the shared 25-minute
|
||||
// world/console wait in launcher_bridge.py, even for older installed scripts.
|
||||
Verb::StartAll | Verb::StartWorld => Duration::from_secs(3600),
|
||||
Verb::StartMysql => Duration::from_secs(360),
|
||||
Verb::StopAll => Duration::from_secs(240),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn squid_enabled(root: &Path) -> bool {
|
||||
let active = root.join("Core/configs/modules/playerbots.conf");
|
||||
let path = if active.exists() {
|
||||
active
|
||||
} else {
|
||||
root.join("Core/configs/modules/playerbots.conf.dist")
|
||||
};
|
||||
std::fs::read(path)
|
||||
.ok()
|
||||
.and_then(|bytes| crate::config::parser::ConfFile::parse_bytes(&bytes).ok())
|
||||
.is_some_and(|config| {
|
||||
config.get("AiPlayerbot.Enabled").is_none_or(|value| {
|
||||
!matches!(
|
||||
value.trim().trim_matches('"').to_ascii_lowercase().as_str(),
|
||||
"0" | "false" | "no" | "off"
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct DriverOutcome {
|
||||
pub ok: bool,
|
||||
@@ -122,7 +103,69 @@ pub(crate) fn validate_update(root: &Path) -> Result<DriverOutcome> {
|
||||
run_inner(root, Verb::StartAll, true)
|
||||
}
|
||||
|
||||
pub fn force_stop(root: &Path) -> Result<DriverOutcome> {
|
||||
if crate::docker::is_docker(root) {
|
||||
return Err(Error::Invalid(
|
||||
"Forced shutdown is only available for Windows repacks.".into(),
|
||||
));
|
||||
}
|
||||
for tree in crate::multiworld::Tree::ALL {
|
||||
let path = crate::multiworld::tree_root(root, tree);
|
||||
if crate::multiworld::tree_running(root, tree)
|
||||
|| !crate::process::find_by_exe(&path.join("Core/worldserver.exe")).is_empty()
|
||||
{
|
||||
let child = fsx::ensure_within(root, &path)?;
|
||||
let out = run_inner_mode(&child, Verb::StopAll, false, true)?;
|
||||
if !out.ok || crate::multiworld::tree_running(root, tree) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"An extra world could not stop; the shared database was left running. {}",
|
||||
out.output
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
run_inner_mode(root, Verb::StopAll, false, true)
|
||||
}
|
||||
|
||||
fn recover_world_for_forced_stop(root: &Path) -> Result<()> {
|
||||
let worlds = crate::process::find_by_exe(&root.join("Core/worldserver.exe"));
|
||||
if worlds.len() > 1 {
|
||||
return Err(Error::Invalid(
|
||||
"More than one world process belongs to this folder; its database was left running."
|
||||
.into(),
|
||||
));
|
||||
}
|
||||
if let Some(world) = worlds.first() {
|
||||
if !crate::process::is_alive(world) {
|
||||
return Err(Error::Invalid(
|
||||
"The world process identity changed; retry the stop.".into(),
|
||||
));
|
||||
}
|
||||
if crate::process::read_state_record(root, "world")
|
||||
.is_none_or(|saved| !saved.same_as(world))
|
||||
{
|
||||
let record = root.join(".state/world.json");
|
||||
let previous = root.join(".state/world-before-forced-stop-recovery.json");
|
||||
if record.is_file() && !previous.exists() {
|
||||
fsx::atomic_write(&previous, &std::fs::read(&record)?)?;
|
||||
}
|
||||
fsx::atomic_write_json(&record, world)?;
|
||||
tracing::warn!(pid = world.pid, root = %root.display(), "Recovered owned world identity before an explicitly requested forced stop");
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverOutcome> {
|
||||
run_inner_mode(root, verb, validating_update, false)
|
||||
}
|
||||
|
||||
fn run_inner_mode(
|
||||
root: &Path,
|
||||
verb: Verb,
|
||||
validating_update: bool,
|
||||
force: bool,
|
||||
) -> Result<DriverOutcome> {
|
||||
let root = fsx::canonicalize_lenient(root)?;
|
||||
let _update_lock = if !validating_update && matches!(verb, Verb::StartAll | Verb::StartWorld) {
|
||||
Some(crate::update::operation_lock_for(
|
||||
@@ -143,7 +186,7 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverO
|
||||
prepare_headless_world(&root, verb)?;
|
||||
return crate::docker::run(&root, verb);
|
||||
}
|
||||
if verb == Verb::StopAll {
|
||||
if verb == Verb::StopAll && !force {
|
||||
crate::multiworld::stop(&root)?;
|
||||
}
|
||||
if verb == Verb::StartAll {
|
||||
@@ -186,12 +229,15 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverO
|
||||
if matches!(verb, Verb::StopAll | Verb::StartMysql | Verb::StartAll) {
|
||||
recover_mysql_launcher_state(&root, verb != Verb::StopAll)?;
|
||||
}
|
||||
if force {
|
||||
recover_world_for_forced_stop(&root)?;
|
||||
}
|
||||
let mut cmd = Command::new(&python);
|
||||
cmd.arg("-B")
|
||||
.arg("-c")
|
||||
.arg(LAUNCH_SCRIPT)
|
||||
.arg(&script)
|
||||
.arg(verb.arg())
|
||||
.arg(if force { "force-stop-all" } else { verb.arg() })
|
||||
.current_dir(&root)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
@@ -230,6 +276,13 @@ fn run_inner(root: &Path, verb: Verb, validating_update: bool) -> Result<DriverO
|
||||
let mut output = out_t.join().unwrap_or_default();
|
||||
output.push_str(&err_t.join().unwrap_or_default());
|
||||
let mut output = output.trim().to_string();
|
||||
if status.is_none() {
|
||||
output.push_str(&format!(
|
||||
"\nLauncher {} exceeded its {} second deadline; check the server logs.",
|
||||
verb.arg(),
|
||||
verb.timeout(&root).as_secs()
|
||||
));
|
||||
}
|
||||
let (ok, exit_code) = match status {
|
||||
Some(st) => (st.success(), st.code()),
|
||||
None => (false, None),
|
||||
@@ -290,6 +343,9 @@ fn prepare_headless_world(root: &Path, verb: Verb) -> Result<()> {
|
||||
if !matches!(verb, Verb::StartAll | Verb::StartWorld) {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(root.join("Core/Crashes"))?;
|
||||
let probe = tempfile::NamedTempFile::new_in(root.join("Core/Crashes"))?;
|
||||
probe.close()?;
|
||||
for relative in [
|
||||
"Settings/worldserver.conf.template",
|
||||
"Core/configs/worldserver.conf",
|
||||
@@ -393,7 +449,7 @@ fn recover_mysql_launcher_state(root: &Path, prefer_listener: bool) -> Result<()
|
||||
Ok(())
|
||||
}
|
||||
|
||||
const LAUNCH_SCRIPT: &str = "import runpy,sys\nfrom pathlib import Path\nscript=sys.argv[1]\nsys.path.insert(0,str(Path(script).resolve().parent))\nsys.argv=sys.argv[1:]\nif sys.argv[1]=='stop-all':\n launcher=runpy.run_path(script,run_name='coa_manager_shutdown')\n config=launcher['settings']()\n with launcher['control_lock']():\n launcher['stop_all'](config)\nelse:\n runpy.run_path(script,run_name='__main__')";
|
||||
const LAUNCH_SCRIPT: &str = include_str!("launcher_bridge.py");
|
||||
|
||||
pub(crate) fn patch_launcher_imports(text: &str) -> Result<String> {
|
||||
let line = "sys.path.insert(0, str(Path(__file__).resolve().parent))";
|
||||
@@ -587,6 +643,7 @@ mod tests {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let root = folder.path();
|
||||
prepare_headless_world(root, Verb::StartAll).unwrap();
|
||||
assert!(root.join("Core/Crashes").is_dir());
|
||||
std::fs::create_dir_all(root.join("Core/configs")).unwrap();
|
||||
let path = root.join("Core/configs/worldserver.conf");
|
||||
for original in [
|
||||
@@ -604,7 +661,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn large_squid_provisioning_has_time_to_finish_without_extending_shutdown() {
|
||||
fn slow_world_startup_has_time_with_either_bot_system_without_extending_shutdown() {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let modules = folder.path().join("Core/configs/modules");
|
||||
std::fs::create_dir_all(&modules).unwrap();
|
||||
@@ -615,7 +672,7 @@ mod tests {
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
Verb::StartAll.timeout(folder.path()),
|
||||
Duration::from_secs(2700)
|
||||
Duration::from_secs(3600)
|
||||
);
|
||||
assert_eq!(
|
||||
Verb::StopAll.timeout(folder.path()),
|
||||
@@ -624,7 +681,11 @@ mod tests {
|
||||
std::fs::write(modules.join("playerbots.conf"), "AiPlayerbot.Enabled = 0\n").unwrap();
|
||||
assert_eq!(
|
||||
Verb::StartWorld.timeout(folder.path()),
|
||||
Duration::from_secs(420)
|
||||
Duration::from_secs(3600)
|
||||
);
|
||||
assert_eq!(
|
||||
Verb::StartMysql.timeout(folder.path()),
|
||||
Duration::from_secs(360)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -163,10 +163,52 @@ pub fn set_open(root: &Path, meta: &Path, open: bool) -> Result<bool> {
|
||||
|
||||
/// Make the realm list advertise `host` to remote clients (the launcher resets it to 127.0.0.1 at every start, so this
|
||||
/// runs after each start).
|
||||
pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
|
||||
fn realm_address_sql(
|
||||
host: &str,
|
||||
local: Option<(std::net::Ipv4Addr, std::net::Ipv4Addr)>,
|
||||
where_realms: &str,
|
||||
) -> Result<String> {
|
||||
if !host_ok(host) {
|
||||
return Err(Error::Invalid("That address is not valid.".into()));
|
||||
}
|
||||
let local = local
|
||||
.map(|(address, mask)| format!(",localAddress='{address}',localSubnetMask='{mask}'"))
|
||||
.unwrap_or_default();
|
||||
Ok(format!(
|
||||
"UPDATE acore_auth.realmlist SET address='{host}'{local} WHERE {where_realms};"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn apply_realm_address(root: &Path, settings: &Settings) -> Result<()> {
|
||||
let host = settings.host.as_deref().unwrap_or("127.0.0.1");
|
||||
let local = match settings.mode {
|
||||
Mode::Direct | Mode::Lan => {
|
||||
let address = if settings.mode == Mode::Lan {
|
||||
host.to_owned()
|
||||
} else {
|
||||
crate::net::resolve_lan_host(
|
||||
settings.lan_address_override.as_deref(),
|
||||
crate::net::lan_ip(),
|
||||
)?
|
||||
};
|
||||
let address = crate::net::validate_lan_address(&address)?;
|
||||
let mask = if_addrs::get_if_addrs()?
|
||||
.into_iter()
|
||||
.find_map(|interface| match interface.addr {
|
||||
if_addrs::IfAddr::V4(v4) if v4.ip == address => Some(v4.netmask),
|
||||
_ => None,
|
||||
})
|
||||
.ok_or_else(|| {
|
||||
Error::Invalid("The selected local network address is not connected.".into())
|
||||
})?;
|
||||
Some((address, mask))
|
||||
}
|
||||
Mode::Local => Some((
|
||||
std::net::Ipv4Addr::LOCALHOST,
|
||||
std::net::Ipv4Addr::new(255, 0, 0, 0),
|
||||
)),
|
||||
Mode::Private => None,
|
||||
};
|
||||
let db = Db::from_repack(root, Account::Admin)?;
|
||||
let state = crate::realms::state(root)?;
|
||||
let mut ids = vec![state.active];
|
||||
@@ -178,9 +220,7 @@ pub fn apply_realm_address(root: &Path, host: &str) -> Result<()> {
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
);
|
||||
db.query(&format!(
|
||||
"UPDATE acore_auth.realmlist SET address='{host}' WHERE {where_realms};"
|
||||
))?;
|
||||
db.query(&realm_address_sql(host, local, &where_realms)?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -198,9 +238,9 @@ pub fn ensure_bind(root: &Path, meta: &Path) -> Result<bool> {
|
||||
/// After every server start: put the chosen address back into the realm list (the launcher resets it to 127.0.0.1).
|
||||
pub fn reapply(root: &Path, meta: &Path) -> Result<()> {
|
||||
let s = load(meta);
|
||||
match (s.mode, s.host) {
|
||||
match (s.mode, &s.host) {
|
||||
(Mode::Local, _) | (_, None) => Ok(()),
|
||||
(_, Some(host)) => apply_realm_address(root, &host),
|
||||
(_, Some(_)) => apply_realm_address(root, &s),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,6 +434,25 @@ CoA.AllowRemoteClients = 0
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn realm_addresses_restore_lan_without_replacing_public_host() {
|
||||
let sql = realm_address_sql(
|
||||
"203.0.113.9",
|
||||
Some((
|
||||
"192.168.50.7".parse().unwrap(),
|
||||
"255.255.254.0".parse().unwrap(),
|
||||
)),
|
||||
"id IN (1,2)",
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(sql, "UPDATE acore_auth.realmlist SET address='203.0.113.9',localAddress='192.168.50.7',localSubnetMask='255.255.254.0' WHERE id IN (1,2);");
|
||||
assert_eq!(
|
||||
realm_address_sql("100.64.1.2", None, "id=1").unwrap(),
|
||||
"UPDATE acore_auth.realmlist SET address='100.64.1.2' WHERE id=1;"
|
||||
);
|
||||
assert!(realm_address_sql("bad'host", None, "id=1").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_settings_load_without_migrating_effective_host_to_override() {
|
||||
let s: Settings = serde_json::from_str(r#"{"mode":"lan","host":"192.168.1.50"}"#).unwrap();
|
||||
|
||||
@@ -239,14 +239,14 @@ fn bootstrap_database_inner(root: &Path) -> Result<()> {
|
||||
}
|
||||
fs::copy(&boot, &target)?; // launcher needs database.json to render its files at first start
|
||||
|
||||
let started = driver::run(root, Verb::StartMysql)?;
|
||||
if !started.ok {
|
||||
return Err(Error::Invalid(format!(
|
||||
"The database could not be started. {}",
|
||||
driver::startup_failure(root, &started)
|
||||
)));
|
||||
}
|
||||
let result = (|| -> Result<()> {
|
||||
let started = driver::run(root, Verb::StartMysql)?;
|
||||
if !started.ok {
|
||||
return Err(Error::Invalid(format!(
|
||||
"The database could not be started. {}",
|
||||
driver::startup_failure(root, &started)
|
||||
)));
|
||||
}
|
||||
let db = Db::from_repack(root, Account::Admin)?;
|
||||
let (root_pw, app_pw) = (random_hex(48), random_hex(48));
|
||||
let accounts = db.query(
|
||||
@@ -362,6 +362,7 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result<Installed> {
|
||||
// 3. Extract into our own staging folder.
|
||||
if staging_root.exists() {
|
||||
if staging_root.join(MARKER).is_file() {
|
||||
stop_installation(&staging_root)?;
|
||||
fs::remove_dir_all(&staging_root)?; // leftover of an earlier failed attempt that we created
|
||||
} else {
|
||||
return Err(Error::Invalid(format!("{} is left over from an earlier installation that did not finish, and the Manager cannot tell whether it may be deleted. Delete that folder yourself, then try again.", staging_root.display())));
|
||||
@@ -426,24 +427,60 @@ pub fn install_base(p: &Params, report: &dyn Fn(Step)) -> Result<Installed> {
|
||||
})();
|
||||
|
||||
if result.is_err() {
|
||||
if staging_root.join(MARKER).exists() {
|
||||
// Unfinished work of ours; the destination was never touched.
|
||||
let _ = driver::run(&staging_root, Verb::StopAll);
|
||||
let _ = fs::remove_dir_all(&staging_root);
|
||||
let unfinished = if staging_root.join(MARKER).is_file() {
|
||||
Some(&staging_root)
|
||||
} else if dest.join(MARKER).is_file() {
|
||||
// Failed after the folder was moved into place. The destination was empty or absent before (preflight),
|
||||
// so what carries our marker is only our unfinished install.
|
||||
let _ = driver::run(&dest, Verb::StopAll);
|
||||
let _ = fs::remove_dir_all(&dest);
|
||||
Some(&dest)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if let Some(unfinished) = unfinished {
|
||||
if let Err(cleanup) = stop_installation(unfinished) {
|
||||
return Err(Error::Invalid(format!(
|
||||
"{} {cleanup}",
|
||||
result.as_ref().unwrap_err()
|
||||
)));
|
||||
}
|
||||
let _ = fs::remove_dir_all(unfinished);
|
||||
}
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn stop_installation(root: &Path) -> Result<()> {
|
||||
if !root.join("Scripts/manage.py").is_file() {
|
||||
return Ok(());
|
||||
}
|
||||
let stopped = driver::run(root, Verb::StopAll)?;
|
||||
if !stopped.ok {
|
||||
return Err(Error::Invalid(format!(
|
||||
"The unfinished installation could not be stopped; its files were kept. {}",
|
||||
driver::startup_failure(root, &stopped)
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn an_unstoppable_partial_installation_keeps_its_files_for_recovery() {
|
||||
let folder = tempfile::tempdir().unwrap();
|
||||
let root = folder.path();
|
||||
fs::create_dir_all(root.join("Scripts")).unwrap();
|
||||
fs::write(root.join("Scripts/manage.py"), "# incomplete launcher").unwrap();
|
||||
fs::write(root.join(MARKER), "").unwrap();
|
||||
fs::write(root.join("database-preserved"), "unchanged").unwrap();
|
||||
assert!(stop_installation(root).is_err());
|
||||
assert_eq!(
|
||||
fs::read_to_string(root.join("database-preserved")).unwrap(),
|
||||
"unchanged"
|
||||
);
|
||||
assert!(root.join(MARKER).is_file());
|
||||
}
|
||||
|
||||
fn reg(d: &Path) -> Registry {
|
||||
Registry::at(d.join("reg/installs.json"))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import runpy
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
script = sys.argv[1]
|
||||
sys.path.insert(0, str(Path(script).resolve().parent))
|
||||
sys.argv = sys.argv[1:]
|
||||
launcher = runpy.run_path(script, run_name="coa_manager_launcher")
|
||||
original_wait = launcher["wait_ready"]
|
||||
world_deadline = None
|
||||
|
||||
|
||||
def wait_ready(name, port, seconds=180):
|
||||
global world_deadline
|
||||
if name == "mysql":
|
||||
seconds = max(seconds, 300)
|
||||
elif name == "world":
|
||||
# Both game and console listeners belong to one startup, including on old repacks.
|
||||
# Ordinary CoA worlds can load slowly too; this must not depend on SQUID.
|
||||
if world_deadline is None:
|
||||
world_deadline = time.monotonic() + max(seconds, 1500)
|
||||
seconds = max(0, world_deadline - time.monotonic())
|
||||
return original_wait(name, port, seconds)
|
||||
|
||||
|
||||
launcher["start_mysql"].__globals__["wait_ready"] = wait_ready
|
||||
if sys.argv[1] in ("stop-all", "force-stop-all"):
|
||||
shutdown = launcher["stop_all"].__globals__
|
||||
class UnsafeShutdown(Exception):
|
||||
pass
|
||||
|
||||
def stop_owned_world(world):
|
||||
expected = Path(script).resolve().parents[1] / "Core" / "worldserver.exe"
|
||||
if Path(world.get("exe", "")) != expected or not shutdown["same_process"](world):
|
||||
raise UnsafeShutdown("World process ownership could not be verified; the database was left running.")
|
||||
try:
|
||||
shutdown["terminate"](world)
|
||||
except (OSError, RuntimeError) as error:
|
||||
raise UnsafeShutdown("Worldserver could not be stopped safely; its database was left running.") from error
|
||||
if not shutdown["wait_stopped"](world, 90):
|
||||
raise UnsafeShutdown("Worldserver is still stopping; its database was left running.")
|
||||
|
||||
if "ra" in shutdown:
|
||||
original_ra = shutdown["ra"]
|
||||
|
||||
def shutdown_command(command, config):
|
||||
try:
|
||||
return original_ra(command, config)
|
||||
except (OSError, RuntimeError):
|
||||
if not command.startswith("server shutdown "):
|
||||
raise
|
||||
world = shutdown["process"]("world")
|
||||
if not world:
|
||||
return "Worldserver has already stopped."
|
||||
stop_owned_world(world)
|
||||
return "Stopped this server's recorded world process without its unavailable console."
|
||||
|
||||
shutdown["ra"] = shutdown_command
|
||||
config = launcher["settings"]()
|
||||
with launcher["control_lock"]():
|
||||
if sys.argv[1] == "force-stop-all":
|
||||
world = shutdown["process"]("world")
|
||||
if world:
|
||||
stop_owned_world(world)
|
||||
print("Force-stopped this server's verified world process; unsaved progress may be lost.", flush=True)
|
||||
launcher["stop_all"](config)
|
||||
else:
|
||||
launcher["main"]()
|
||||
if sys.argv[1] in ("start-all", "start-world"):
|
||||
config = launcher["settings"]()
|
||||
deadline = world_deadline if world_deadline is not None else time.monotonic() + 1500
|
||||
last_error = None
|
||||
while time.monotonic() < deadline:
|
||||
if not launcher["process"]("world"):
|
||||
raise RuntimeError("World startup failed: worldserver stopped before its console accepted commands.")
|
||||
try:
|
||||
launcher["ra"]("server info", config)
|
||||
print("World console is accepting commands.", flush=True)
|
||||
break
|
||||
except (OSError, RuntimeError) as error:
|
||||
last_error = error
|
||||
time.sleep(0.5)
|
||||
else:
|
||||
raise RuntimeError(f"World startup failed: its console is still starting. {last_error}")
|
||||
@@ -14,8 +14,10 @@ pub mod backup;
|
||||
pub mod build_engine;
|
||||
pub mod catalogue;
|
||||
pub mod channels;
|
||||
pub mod character_start_check;
|
||||
pub mod cleanbase;
|
||||
pub mod client;
|
||||
pub mod client_graphics;
|
||||
pub mod clientdl;
|
||||
pub mod companions;
|
||||
pub mod config;
|
||||
@@ -60,6 +62,7 @@ pub mod realmlist;
|
||||
pub mod realms;
|
||||
pub mod registry;
|
||||
pub mod release;
|
||||
pub mod release_history;
|
||||
pub mod release_schema;
|
||||
pub mod remote_client;
|
||||
pub mod repair;
|
||||
|
||||
Loaded 100 of 327 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user