Logging in to an account with no characters lands on the empty character list, as in 1.12.1, instead of creating a Human Warrior (#457)
Logging in to an account or realm with no characters lands on the empty character list. Until now the login cycle created a male Human Warrior on the server, named `WOW_CHAR` or "One", on every login that found an empty roster, a typed one included and again after Change Realm. - **The reference.** The client builds `CMSG_CHAR_CREATE` in one place, `0x5aac50`, reached only from the glue binding `CreateCharacter` (`0x4719e0` → `0x4711d0` → `0x46b290` → `0x5aac50`), which stock `CharacterCreate.lua` calls only from `CharacterCreate_Okay`. With an empty roster, stock `CharacterSelect.lua`'s `UpdateCharacterList` disables Enter World and Delete and shows Create New Character, which benilla's select screen already does. - **The change.** The create-if-empty step is gone from the per-realm cycle, and `NetConfig`, which existed only for it, goes with it. The create screen's own path is untouched. - **Scripted runs.** `WOW_CHAR` must now be on the account: a driverless run whose `WOW_CHAR` is missing fails at select, as it already did on a non-empty roster, and a player's run shows the list. `docs/CONTRIBUTING.md` says so, and names `WOW_PROBE_CHARCREATE` as the explicit way to make one. Checked: `an_empty_account_parks_at_select_without_creating_a_character` drives the real login cycle against a fake realmd (the server half of SRP6) and a fake world server answering an empty roster. It fails with the old step restored (the client sends `CMSG_CHAR_CREATE` on its own) and passes without it. Live against a local vmangos on a fresh account with no characters: before, the server gained a level-1 "One"; after, the client parks on the empty list and the account still holds no character. With a `WOW_CHAR` not on the account, a driverless run exits at select, and nothing is created. Gates and the smoke run green on macOS; no platform seam touched. Fixes #343 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
committed by
GitHub
co-authored by
Claude Opus 5.5
parent
0106b66b25
commit
243fd09154
Generated
+3
@@ -512,6 +512,7 @@ dependencies = [
|
||||
"benilla-assets",
|
||||
"benilla-formats",
|
||||
"benilla-protocol",
|
||||
"benilla-srp",
|
||||
"benilla-ui",
|
||||
"benilla-world",
|
||||
"bevy",
|
||||
@@ -528,6 +529,7 @@ dependencies = [
|
||||
"kira",
|
||||
"libc",
|
||||
"mint",
|
||||
"num-bigint",
|
||||
"objc2 0.5.2",
|
||||
"objc2-app-kit 0.2.2",
|
||||
"objc2-core-audio",
|
||||
@@ -538,6 +540,7 @@ dependencies = [
|
||||
"regex",
|
||||
"rtrb",
|
||||
"serde",
|
||||
"sha1",
|
||||
"smithay-clipboard",
|
||||
"symphonia",
|
||||
"toml",
|
||||
|
||||
@@ -96,3 +96,9 @@ cpal = "0.17"
|
||||
# Windows CPU clocks for `perf::clock`.
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_System_Threading"] }
|
||||
|
||||
# The login cycle's fake realmd runs the server half of SRP6 (`net::io::cycle_tests`).
|
||||
[dev-dependencies]
|
||||
benilla-srp = { path = "../benilla-srp" }
|
||||
num-bigint = "0.4.6"
|
||||
sha1.workspace = true
|
||||
|
||||
@@ -66,7 +66,7 @@ impl Plugin for NetPlugin {
|
||||
crate::query_cache::register::<crate::names::NameCache>(app);
|
||||
crate::query_cache::register::<crate::go_templates::GameObjectTemplates>(app);
|
||||
crate::query_cache::register::<crate::items::Items>(app);
|
||||
let handles = io::spawn_net(io::NetConfig::from_env(), self.connect);
|
||||
let handles = io::spawn_net(self.connect);
|
||||
if !self.connect {
|
||||
app.insert_resource(NetOffline);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{anyhow, bail, Result};
|
||||
use anyhow::{anyhow, Result};
|
||||
use benilla_assets::LockRecover;
|
||||
use benilla_protocol::{
|
||||
host_port, messages, AuthReject, CharAction, LoginStage, Poll, SessionEnd, SessionEvent,
|
||||
@@ -145,21 +145,6 @@ impl PingClock {
|
||||
/// Cap on send warnings per connection, so a movement stream during an outage cannot flood the log.
|
||||
const SEND_WARN_CAP: u32 = 8;
|
||||
|
||||
/// The per-process connection parameters. Credentials and address ride each [`LoginRequest`];
|
||||
/// `$WOW_HOST` is read by `Realmlist::default()`.
|
||||
pub(super) struct NetConfig {
|
||||
/// `WOW_CHAR`: here only the name of the starter character on an empty account.
|
||||
character: Option<String>,
|
||||
}
|
||||
|
||||
impl NetConfig {
|
||||
pub(super) fn from_env() -> Self {
|
||||
NetConfig {
|
||||
character: std::env::var("WOW_CHAR").ok(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What one wake-up at the character park asked for, so every jump out is made outside `select!`.
|
||||
enum Parked {
|
||||
/// `CMSG_PLAYER_LOGIN` with this guid.
|
||||
@@ -204,7 +189,7 @@ pub(super) struct NetHandles {
|
||||
}
|
||||
|
||||
/// Spawns the read thread with its park and cycle loop, and the one long-lived write thread.
|
||||
pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
|
||||
pub(super) fn spawn_net(connect: bool) -> NetHandles {
|
||||
let (events_tx, events_rx) = crossbeam_channel::unbounded();
|
||||
let (cmd_tx, cmd_rx) = crossbeam_channel::unbounded();
|
||||
let (pick_tx, pick_rx) = crossbeam_channel::unbounded::<CharRequest>();
|
||||
@@ -247,7 +232,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
|
||||
// writer, since the keepalive can still fire on the stale one until then.
|
||||
read_clock.lock_recover().clear();
|
||||
match run(
|
||||
&cfg,
|
||||
&events_tx,
|
||||
&writer_tx,
|
||||
&parks,
|
||||
@@ -310,7 +294,6 @@ pub(super) fn spawn_net(cfg: NetConfig, connect: bool) -> NetHandles {
|
||||
/// dies, the character logs out or the app exits. A pre-roster failure emits
|
||||
/// [`SessionEvent::LoginFailed`] and re-parks; any retry is the app's.
|
||||
fn run(
|
||||
cfg: &NetConfig,
|
||||
events_tx: &Sender<SessionEvent>,
|
||||
writer_tx: &Sender<WorldWriter>,
|
||||
parks: &Parks,
|
||||
@@ -501,31 +484,10 @@ fn run(
|
||||
return Ok(Cycle::Repark);
|
||||
}
|
||||
|
||||
// The roster, creating a starter character on an empty account so `PLAYER_LOGIN` has a
|
||||
// target. Failures here are still pre-roster.
|
||||
let mut characters = match (|| -> Result<Vec<benilla_protocol::Character>> {
|
||||
let mut characters = session.char_enum()?;
|
||||
if characters.is_empty() {
|
||||
let name = cfg.character.as_deref().unwrap_or("One");
|
||||
let starter = messages::CharCreateReq {
|
||||
name: name.to_string(),
|
||||
race: messages::RACE_HUMAN,
|
||||
class: messages::CLASS_WARRIOR,
|
||||
gender: messages::GENDER_MALE,
|
||||
skin: 0,
|
||||
face: 0,
|
||||
hair_style: 0,
|
||||
hair_color: 0,
|
||||
facial_hair: 0,
|
||||
};
|
||||
match session.create_character(&starter)? {
|
||||
messages::CHAR_CREATE_SUCCESS | messages::CHAR_CREATE_NAME_IN_USE => {}
|
||||
other => bail!("character creation failed: result {other:#x}"),
|
||||
}
|
||||
characters = session.char_enum()?;
|
||||
}
|
||||
Ok(characters)
|
||||
})() {
|
||||
// The roster as the account holds it, an empty one included: the reference builds
|
||||
// `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the create screen.
|
||||
// Failures here are still pre-roster.
|
||||
let mut characters = match session.char_enum() {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
if canceled() {
|
||||
@@ -1530,6 +1492,9 @@ fn writer_loop(
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod cycle_tests;
|
||||
|
||||
#[cfg(test)]
|
||||
mod rtt_tests {
|
||||
use super::{PingClock, RTT_RING};
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
//! The login cycle, [`super::run`], against a fake realmd and a fake world server on loopback: the
|
||||
//! real logon, realm park, world handshake and roster, driven through the parks as the app does.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
use std::sync::atomic::AtomicU64;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use benilla_protocol::messages::{self, opcode};
|
||||
use benilla_protocol::{SessionEvent, WorldWriter};
|
||||
use benilla_srp::vanilla_header::HeaderCrypto;
|
||||
use benilla_srp::{
|
||||
password_verifier, NormalizedString, GENERATOR, LARGE_SAFE_PRIME_LITTLE_ENDIAN,
|
||||
SESSION_KEY_LENGTH,
|
||||
};
|
||||
use crossbeam_channel::{Receiver, Sender};
|
||||
use num_bigint::BigUint;
|
||||
use sha1::{Digest, Sha1};
|
||||
|
||||
use super::{run, Cycle, LoginRequest, Parks, PingClock};
|
||||
use crate::net::{CharRequest, RealmRequest};
|
||||
|
||||
const USER: &str = "empty";
|
||||
const PASS: &str = "secret";
|
||||
const REALM: &str = "Fake";
|
||||
const SALT: [u8; 32] = [0x5a; 32];
|
||||
/// Every wait in the test: long enough for a loaded machine, short of a hung gate.
|
||||
const WAIT: Duration = Duration::from_secs(20);
|
||||
|
||||
fn sha1(parts: &[&[u8]]) -> [u8; 20] {
|
||||
let mut h = Sha1::new();
|
||||
for p in parts {
|
||||
h.update(p);
|
||||
}
|
||||
h.finalize().into()
|
||||
}
|
||||
|
||||
fn le32(v: &BigUint) -> [u8; 32] {
|
||||
let raw = v.to_bytes_le();
|
||||
let mut out = [0u8; 32];
|
||||
out[..raw.len()].copy_from_slice(&raw);
|
||||
out
|
||||
}
|
||||
|
||||
/// `SHA1_Interleave` over all 32 bytes of `S`, as vmangos's `SRP6::HashSessionKey` does.
|
||||
fn interleave(s: &[u8; 32]) -> [u8; SESSION_KEY_LENGTH] {
|
||||
let even: Vec<u8> = s.iter().step_by(2).copied().collect();
|
||||
let odd: Vec<u8> = s.iter().skip(1).step_by(2).copied().collect();
|
||||
let (g, h) = (sha1(&[&even]), sha1(&[&odd]));
|
||||
let mut out = [0u8; SESSION_KEY_LENGTH];
|
||||
for (i, (gi, hi)) in g.iter().zip(&h).enumerate() {
|
||||
out[i * 2] = *gi;
|
||||
out[i * 2 + 1] = *hi;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A realmd that logs [`USER`] in with the server half of SRP6 and lists one realm, [`REALM`] at
|
||||
/// `world`. The session key goes to the fake world server, which needs it for the header cipher.
|
||||
fn fake_realmd(world: String, keys: Sender<[u8; SESSION_KEY_LENGTH]>) -> String {
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let addr = listener.local_addr().unwrap().to_string();
|
||||
thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
let n = BigUint::from_bytes_le(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
|
||||
let g = BigUint::from(GENERATOR);
|
||||
let user = NormalizedString::new(USER).unwrap();
|
||||
let pass = NormalizedString::new(PASS).unwrap();
|
||||
let v = BigUint::from_bytes_le(&password_verifier(&user, &pass, &SALT));
|
||||
|
||||
// CMD_AUTH_LOGON_CHALLENGE_Client: opcode, protocol, u16 size, then the body.
|
||||
let mut head = [0u8; 4];
|
||||
s.read_exact(&mut head).unwrap();
|
||||
let mut body = vec![0u8; usize::from(u16::from_le_bytes([head[2], head[3]]))];
|
||||
s.read_exact(&mut body).unwrap();
|
||||
|
||||
// B = k·v + g^b, with k = 3; `b` walks until `B`'s top byte is non-zero, since the client
|
||||
// redials on a zero one.
|
||||
let (b, b_pub) = (1u32..)
|
||||
.map(|i| {
|
||||
let b = BigUint::from_bytes_le(&[0x3c; 32]) + i;
|
||||
let b_pub = le32(&((BigUint::from(3u8) * &v + g.modpow(&b, &n)) % &n));
|
||||
(b, b_pub)
|
||||
})
|
||||
.find(|(_, b_pub)| b_pub[31] != 0)
|
||||
.unwrap();
|
||||
let mut reply = vec![0x00, 0x00, 0x00];
|
||||
reply.extend_from_slice(&b_pub);
|
||||
reply.extend_from_slice(&[1, GENERATOR, 32]);
|
||||
reply.extend_from_slice(&LARGE_SAFE_PRIME_LITTLE_ENDIAN);
|
||||
reply.extend_from_slice(&SALT);
|
||||
reply.extend_from_slice(&[0u8; 16]); // crc_salt
|
||||
reply.push(0); // security_flag
|
||||
s.write_all(&reply).unwrap();
|
||||
|
||||
// CMD_AUTH_LOGON_PROOF_Client: opcode, A, M1, crc_hash, key count, security flag.
|
||||
let mut proof = [0u8; 75];
|
||||
s.read_exact(&mut proof).unwrap();
|
||||
let (a_pub, m1) = (&proof[1..33], &proof[33..53]);
|
||||
let u = BigUint::from_bytes_le(&sha1(&[a_pub, &b_pub]));
|
||||
let a = BigUint::from_bytes_le(a_pub);
|
||||
let big_s = (a * v.modpow(&u, &n) % &n).modpow(&b, &n);
|
||||
let key = interleave(&le32(&big_s));
|
||||
let mut reply = vec![0x01, 0x00];
|
||||
reply.extend_from_slice(&sha1(&[a_pub, m1, &key]));
|
||||
reply.extend_from_slice(&0u32.to_le_bytes()); // hardware_survey_id
|
||||
s.write_all(&reply).unwrap();
|
||||
keys.send(key).unwrap();
|
||||
|
||||
// CMD_REALM_LIST_Client, answered with the one realm.
|
||||
s.read_exact(&mut [0u8; 5]).unwrap();
|
||||
let mut realm = Vec::new();
|
||||
realm.extend_from_slice(&0u32.to_le_bytes()); // realm_type
|
||||
realm.push(0); // flags
|
||||
realm.extend_from_slice(format!("{REALM}\0{world}\0").as_bytes());
|
||||
realm.extend_from_slice(&0f32.to_le_bytes()); // population
|
||||
realm.extend_from_slice(&[0, 1, 1]); // characters, category, id
|
||||
let mut list = vec![0x10];
|
||||
list.extend_from_slice(&((4 + 1 + realm.len() + 2) as u16).to_le_bytes());
|
||||
list.extend_from_slice(&0u32.to_le_bytes());
|
||||
list.push(1);
|
||||
list.extend_from_slice(&realm);
|
||||
list.extend_from_slice(&0u16.to_le_bytes());
|
||||
s.write_all(&list).unwrap();
|
||||
|
||||
// Held until the cycle drops the logon, as realmd keeps it for the list refresh.
|
||||
let _ = s.read(&mut [0u8; 1]);
|
||||
});
|
||||
addr
|
||||
}
|
||||
|
||||
/// A server packet: `u16` BE size counting the opcode, `u16` LE opcode, the body.
|
||||
fn send(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>, op: u16, body: &[u8]) {
|
||||
let size = ((body.len() + 2) as u16).to_be_bytes();
|
||||
let op = op.to_le_bytes();
|
||||
let mut header = [size[0], size[1], op[0], op[1]];
|
||||
if let Some(c) = crypto {
|
||||
c.encrypter().encrypt(&mut header);
|
||||
}
|
||||
s.write_all(&header).unwrap();
|
||||
s.write_all(body).unwrap();
|
||||
}
|
||||
|
||||
/// A client packet's opcode, reading past its body: `u16` BE size counting the 4-byte opcode.
|
||||
fn recv(s: &mut TcpStream, crypto: Option<&mut HeaderCrypto>) -> Option<u16> {
|
||||
let mut header = [0u8; 6];
|
||||
s.read_exact(&mut header).ok()?;
|
||||
if let Some(c) = crypto {
|
||||
c.decrypter().decrypt(&mut header);
|
||||
}
|
||||
let size = usize::from(u16::from_be_bytes([header[0], header[1]]));
|
||||
s.read_exact(&mut vec![0u8; size.saturating_sub(4)]).ok()?;
|
||||
Some(u16::from_le_bytes([header[2], header[3]]))
|
||||
}
|
||||
|
||||
/// A world server whose account has no characters: it admits the session under realmd's key,
|
||||
/// answers every `CMSG_CHAR_ENUM` with an empty roster and a `CMSG_CHAR_CREATE` with success, and
|
||||
/// logs every opcode the client sends.
|
||||
fn fake_world(
|
||||
listener: TcpListener,
|
||||
keys: Receiver<[u8; SESSION_KEY_LENGTH]>,
|
||||
) -> Arc<Mutex<Vec<u16>>> {
|
||||
let sent = Arc::new(Mutex::new(Vec::new()));
|
||||
let log = Arc::clone(&sent);
|
||||
thread::spawn(move || {
|
||||
let (mut s, _) = listener.accept().unwrap();
|
||||
send(
|
||||
&mut s,
|
||||
None,
|
||||
opcode::SMSG_AUTH_CHALLENGE,
|
||||
&0xDEAD_BEEFu32.to_le_bytes(),
|
||||
);
|
||||
assert_eq!(recv(&mut s, None), Some(opcode::CMSG_AUTH_SESSION));
|
||||
let mut crypto = HeaderCrypto::from_session_key(keys.recv_timeout(WAIT).unwrap());
|
||||
send(
|
||||
&mut s,
|
||||
Some(&mut crypto),
|
||||
opcode::SMSG_AUTH_RESPONSE,
|
||||
&[messages::AUTH_OK],
|
||||
);
|
||||
while let Some(op) = recv(&mut s, Some(&mut crypto)) {
|
||||
log.lock().unwrap().push(op);
|
||||
match op {
|
||||
opcode::CMSG_CHAR_ENUM => {
|
||||
send(&mut s, Some(&mut crypto), opcode::SMSG_CHAR_ENUM, &[0]);
|
||||
}
|
||||
opcode::CMSG_CHAR_CREATE => send(
|
||||
&mut s,
|
||||
Some(&mut crypto),
|
||||
opcode::SMSG_CHAR_CREATE,
|
||||
&[messages::CHAR_CREATE_SUCCESS],
|
||||
),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
sent
|
||||
}
|
||||
|
||||
/// The next event, skipping the stage announcements; a login failure fails the test by name.
|
||||
fn next_event(events: &Receiver<SessionEvent>) -> SessionEvent {
|
||||
loop {
|
||||
match events.recv_timeout(WAIT).expect("the cycle went quiet") {
|
||||
SessionEvent::LoginStage { .. } => continue,
|
||||
SessionEvent::LoginFailed { reason, .. } => panic!("the login failed: {reason}"),
|
||||
ev => return ev,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The reference builds `CMSG_CHAR_CREATE` in one place (`0x5aac50`), reached only from the glue's
|
||||
/// `CreateCharacter`, which the stock glue calls only from the create screen's Okay; so a login to
|
||||
/// an account with no characters parks at select on the empty list and creates nothing.
|
||||
#[test]
|
||||
fn an_empty_account_parks_at_select_without_creating_a_character() {
|
||||
let world_listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let world = world_listener.local_addr().unwrap().to_string();
|
||||
let (keys_tx, keys_rx) = crossbeam_channel::unbounded();
|
||||
let sent = fake_world(world_listener, keys_rx);
|
||||
let realmd = fake_realmd(world, keys_tx);
|
||||
|
||||
let (events_tx, events) = crossbeam_channel::unbounded();
|
||||
let (writer_tx, _writer_rx) = crossbeam_channel::unbounded::<WorldWriter>();
|
||||
let (login_tx, login_rx) = crossbeam_channel::unbounded();
|
||||
let (realm_tx, realm_rx) = crossbeam_channel::unbounded();
|
||||
let (pick_tx, pick_rx) = crossbeam_channel::unbounded();
|
||||
let cycle = thread::spawn(move || {
|
||||
let parks = Parks {
|
||||
login_rx,
|
||||
realm_rx,
|
||||
pick_rx,
|
||||
};
|
||||
let ended = run(
|
||||
&events_tx,
|
||||
&writer_tx,
|
||||
&parks,
|
||||
&AtomicU64::new(0),
|
||||
&Mutex::new(PingClock::default()),
|
||||
&mut Default::default(),
|
||||
);
|
||||
matches!(ended, Ok(Cycle::Repark))
|
||||
});
|
||||
|
||||
login_tx
|
||||
.send(LoginRequest {
|
||||
user: USER.into(),
|
||||
pass: PASS.into(),
|
||||
host: realmd,
|
||||
generation: 0,
|
||||
})
|
||||
.unwrap();
|
||||
match next_event(&events) {
|
||||
SessionEvent::RealmList { realms } => assert_eq!(realms[0].name, REALM),
|
||||
other => panic!("expected the realm list, got {other:?}"),
|
||||
}
|
||||
realm_tx.send(RealmRequest::Enter(REALM.into())).unwrap();
|
||||
match next_event(&events) {
|
||||
SessionEvent::CharacterList { characters, .. } => assert!(
|
||||
characters.is_empty(),
|
||||
"the roster at select must be the account's, empty: {characters:?}"
|
||||
),
|
||||
other => panic!("expected the roster at select, got {other:?}"),
|
||||
}
|
||||
// Select's Back ends the cycle, so the socket is done and its log complete.
|
||||
pick_tx.send(CharRequest::Abandon).unwrap();
|
||||
assert!(cycle.join().unwrap(), "Back returns to the login park");
|
||||
let sent = sent.lock().unwrap().clone();
|
||||
assert!(
|
||||
!sent.contains(&opcode::CMSG_CHAR_CREATE),
|
||||
"the login sent CMSG_CHAR_CREATE on its own: {sent:#06x?}"
|
||||
);
|
||||
}
|
||||
@@ -73,8 +73,10 @@ out of scope is closed with the reason.
|
||||
`localhost:3724`). A scripted run has no default account: `WOW_USER`, `WOW_PASS` and
|
||||
`WOW_CHAR` name a test account on your server whose login kicks nobody, all three, either in
|
||||
the environment or in a `.probe-identity` file at the repo root (one per line, never
|
||||
committed), and `scripts/smoke.sh` (the live login gate) refuses without them. The probes
|
||||
drive the body with GM commands, so give that account the top GM level.
|
||||
committed), and `scripts/smoke.sh` (the live login gate) refuses without them. `WOW_CHAR`
|
||||
must already be on the account, since the client creates a character only when asked:
|
||||
`WOW_PROBE_CHARCREATE=<name> WOW_PROBE_CHARCREATE_KEEP=1`, run without `WOW_CHAR`, makes it.
|
||||
The probes drive the body with GM commands, so give that account the top GM level.
|
||||
- **Running it unattended.** The rules are `docs/METHOD.md`, "The local server"; these are the
|
||||
switches.
|
||||
- `WOW_UNATTENDED=1` reconnects instead of waiting at a dialog, and exits non-zero on a login
|
||||
|
||||
+1
-1
@@ -182,7 +182,7 @@
|
||||
- `WOW_CAPTURE_STABLE` — benilla-app/src/capture/mod.rs
|
||||
- `WOW_CAPTURE_UI` — benilla-app/src/capture/scenarios.rs, benilla-app/src/ui_script/mod.rs, benilla-app/src/video.rs
|
||||
- `WOW_CAST_TRACE` — benilla-app/src/net.rs
|
||||
- `WOW_CHAR` — benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/net/io.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs
|
||||
- `WOW_CHAR` — benilla-app/src/capture/probe_mail.rs, benilla-app/src/char_select/mod.rs, benilla-app/src/cvars.rs, benilla-app/src/login/mod.rs, benilla-app/src/net.rs, benilla-app/src/realm_select/smoke.rs, benilla-app/src/run_mode.rs
|
||||
- `WOW_CHARCREATE_DIALS` — benilla-app/src/char_create/mod.rs
|
||||
- `WOW_CHARCREATE_NAME` — benilla-app/src/char_create/mod.rs
|
||||
- `WOW_CHARCREATE_PICK` — benilla-app/src/capture/mod.rs, benilla-app/src/char_create/mod.rs
|
||||
|
||||
Reference in New Issue
Block a user