fix(control): MR2.1 fail-closed world-port resolution and ruleset invariant verification

- Make world_port resolution fallible (Result<u16>) across LocalBackend, HostService, DirectRouteManager, and RelayLink without default fallbacks to 8085.
- Enforce strict 4-way invariant: local_id ruleset == RealmAccess.mode == Db.realm() == RealmInfo.ruleset. Malformed or unknown suffixes fail closed.
- If Wildcard is selected and world-port resolution fails (or simultaneous mode is disabled), Direct and Relay fail closed with actionable error and zero fallback to CoA.
- Fix documentation drift in docs/WILDCARD.md and docs/MULTIRULESET_PORTABLE_AUDIT.md regarding simultaneous execution, distinct ports, and current vs historical baseline.
This commit is contained in:
Дмитрий committed 2026-10-22 18:10:49 +05:00
1 parent 5343020bed
commit ba143e1ccd
16 files changed
+463 -135

No files matched your search

+165 -12
View File
@@ -64,8 +64,31 @@ impl LocalBackend {
}
fn access(&self, local_id: &str) -> Result<RealmAccess> {
find_access(&self.descriptors, &(self.installs)(), local_id)
.ok_or_else(|| Error::Invalid("That realm is not known to this Manager.".into()))
let access =
find_access(&self.descriptors, &(self.installs)(), local_id).ok_or_else(|| {
Error::Invalid(format!(
"That realm is not known to this Manager: {local_id}"
))
})?;
if let Some((_base, rs)) = local_id.split_once(':') {
let expected_mode = match rs {
"coa" => crate::realms::Mode::Coa,
"wildcard" => crate::realms::Mode::Wildcard,
"reborn" => crate::realms::Mode::Reborn,
_ => {
return Err(Error::Invalid(format!(
"Malformed or unknown ruleset suffix in local_id: {rs}"
)))
}
};
if access.mode != Some(expected_mode) {
return Err(Error::Invalid(format!(
"Realm access mode {:?} does not match expected ruleset in local_id {local_id}",
access.mode
)));
}
}
Ok(access)
}
fn account(&self, local_id: &str, filter: &str) -> Result<Option<AccountRow>> {
@@ -89,6 +112,32 @@ impl RealmBackend for LocalBackend {
let access = self.access(local_id)?;
let db = access.db()?;
let ruleset = realm::ruleset_of(&db).map_err(|e| Error::Invalid(e.to_string()))?;
// Explicit local-id -> ruleset invariant:
// local_id ruleset == RealmAccess.mode == Db.realm() == RealmInfo.ruleset
if let Some((_base, rs)) = local_id.split_once(':') {
let expected_ruleset: crate::portable::Ruleset = rs
.parse()
.map_err(|_| Error::Invalid(format!("Unknown ruleset in local_id: {rs}")))?;
if ruleset != expected_ruleset {
return Err(Error::Invalid(format!(
"Ruleset of realm DB ({ruleset:?}) does not match local_id {local_id}"
)));
}
if access.mode.map(|m| m.name()) != Some(expected_ruleset.as_str()) {
return Err(Error::Invalid(format!(
"RealmAccess mode ({:?}) does not match local_id {local_id}",
access.mode
)));
}
if db.realm().name() != expected_ruleset.as_str() {
return Err(Error::Invalid(format!(
"Db realm ({:?}) does not match local_id {local_id}",
db.realm()
)));
}
}
Ok(RealmInfo {
automatic: cfg.as_ref().is_none_or(|c| !c.existing_only),
route: cfg.and_then(|c| c.route),
@@ -256,11 +305,22 @@ impl RealmBackend for LocalBackend {
}
}
let access = self.access(local_id)?;
let mode = access.mode.unwrap_or(crate::realms::Mode::Coa);
let mode = if let Some(m) = access.mode {
m
} else if let Some((_base, _rs)) = local_id.split_once(':') {
return Err(Error::Invalid(format!(
"Unresolved mode for ruleset suffix in {local_id}"
)));
} else {
// Legacy unsuffixed CoA realm ONLY
crate::realms::Mode::Coa
};
if let Some(ref root) = access.root {
crate::realms::world_port_for_mode(root, mode)
} else {
Ok(8085)
Err(Error::Invalid(format!(
"No server installation root for {local_id} to resolve world port"
)))
}
}
}
@@ -282,8 +342,8 @@ mod tests {
fs::create_dir_all(&desc_dir).unwrap();
let portable_dir = dir.path().join("portable");
let rt = PortableRuntime::start(&portable_dir, Box::new(|| vec![]), None).unwrap();
let backend = LocalBackend::new(Arc::new(rt), &settings, &desc_dir, Arc::new(|| vec![]));
let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap();
let backend = LocalBackend::new(Arc::new(rt), &settings, &desc_dir, Arc::new(Vec::new));
// Unknown realm id must return Err, never Ok(RealmInfo { ruleset: Ruleset::Coa, .. })
assert!(backend.info("srv-unknown").is_err());
@@ -296,11 +356,7 @@ mod tests {
let dir = tempdir().unwrap();
let root = dir.path().join("srv");
fs::create_dir_all(root.join("Settings")).unwrap();
fs::write(
root.join("Settings/repack.json"),
r#"{"worldPort": 8085}"#,
)
.unwrap();
fs::write(root.join("Settings/repack.json"), r#"{"worldPort": 8085}"#).unwrap();
fs::write(
root.join("Settings/realm-profile.json"),
r#"{"active": "coa", "wildcard_created": true, "simultaneous": true, "secondary_world_port": 8086}"#,
@@ -318,7 +374,7 @@ mod tests {
let desc_dir = dir.path().join("descriptors");
let portable_dir = dir.path().join("portable");
let rt = PortableRuntime::start(&portable_dir, Box::new(|| vec![]), None).unwrap();
let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap();
let root_clone = root.clone();
let backend = LocalBackend::new(
Arc::new(rt),
@@ -329,5 +385,102 @@ mod tests {
assert_eq!(backend.world_port("srv-test1:coa").unwrap(), 8085);
assert_eq!(backend.world_port("srv-test1:wildcard").unwrap(), 8086);
// When simultaneous is false and active is CoA:
fs::write(
root.join("Settings/realm-profile.json"),
r#"{"active": "coa", "wildcard_created": true, "simultaneous": false}"#,
)
.unwrap();
// CoA succeeds with 8085
assert_eq!(backend.world_port("srv-test1:coa").unwrap(), 8085);
// Wildcard must FAIL CLOSED, never return 8085
let err_wc_mode =
crate::realms::world_port_for_mode(&root, crate::realms::Mode::Wildcard).unwrap_err();
assert!(
err_wc_mode.to_string().contains("not active"),
"Expected not active error, got: {err_wc_mode}"
);
assert!(backend.world_port("srv-test1:wildcard").is_err());
// Reborn must FAIL CLOSED
let err_reb_mode =
crate::realms::world_port_for_mode(&root, crate::realms::Mode::Reborn).unwrap_err();
assert!(
err_reb_mode.to_string().contains("not active"),
"Expected not active error, got: {err_reb_mode}"
);
assert!(backend.world_port("srv-test1:reborn").is_err());
// Malformed and unknown suffixes must fail closed
assert!(backend.access("srv-test1:unknown").is_err());
assert!(backend.access("srv-test1:garbage").is_err());
assert!(backend.access("srv-test1:coa:extra").is_err());
assert!(backend.world_port("srv-test1:unknown").is_err());
assert!(backend.world_port("srv-test1:garbage").is_err());
}
#[test]
fn world_port_lookup_failure_causes_direct_and_relay_to_fail_closed() {
let dir = tempdir().unwrap();
let root = dir.path().join("srv");
fs::create_dir_all(root.join("Settings")).unwrap();
fs::write(root.join("Settings/repack.json"), r#"{"worldPort": 8085}"#).unwrap();
// Server only has active CoA, simultaneous is off
fs::write(
root.join("Settings/realm-profile.json"),
r#"{"active": "coa", "wildcard_created": false, "simultaneous": false}"#,
)
.unwrap();
let desc_dir = dir.path().join("descriptors");
let portable_dir = dir.path().join("portable");
let rt = PortableRuntime::start(&portable_dir, Box::new(Vec::new), None).unwrap();
let root_clone = root.clone();
let backend = Arc::new(LocalBackend::new(
Arc::new(rt),
root.join("Settings"),
&desc_dir,
Arc::new(move || vec![("test1".to_string(), root_clone.clone())]),
));
let store = Arc::new(std::sync::Mutex::new(
crate::control::store::ControlStore::open_in_memory().unwrap(),
));
let service = Arc::new(crate::control::service::HostService::new(
"srv-test1:wildcard",
coa_registry_proto::sign::RealmId::new(),
store,
backend,
));
// 1. HostService::local_world_port fails closed
assert!(service.local_world_port().is_err());
// 2. Direct route manager cannot find a direct route because world port resolution failed
let mut direct_mgr =
crate::control::direct_route::DirectRouteManager::start(None, service.clone());
assert!(!direct_mgr.is_direct_ready());
assert!(service.direct_route().is_none());
direct_mgr.shutdown();
// 3. When relay link has no world port resolved, AllocateRelay refuses
let mut session = crate::control::service::Session::default();
let player = uuid::Uuid::now_v7();
let resp = service.handle(
&mut session,
&player,
"KEY",
coa_control_proto::app::Request::AllocateRelay { player_id: player },
);
assert!(matches!(
resp,
coa_control_proto::app::Response::Error {
code: coa_control_proto::app::AppError::NoAccount
| coa_control_proto::app::AppError::RelayUnavailable,
..
}
));
}
}
+19 -5
View File
@@ -33,7 +33,11 @@ impl DirectIngress {
local_auth_port: u16,
local_world_port: u16,
) -> Result<Self> {
let auth_host = if auth_bind_port == 0 { "127.0.0.1" } else { "0.0.0.0" };
let auth_host = if auth_bind_port == 0 {
"127.0.0.1"
} else {
"0.0.0.0"
};
let auth_listener =
TcpListener::bind(format!("{auth_host}:{auth_bind_port}")).map_err(|e| {
Error::Invalid(format!(
@@ -48,7 +52,11 @@ impl DirectIngress {
.map_err(|e| Error::Invalid(e.to_string()))?
.port();
let world_host = if world_bind_port == 0 { "127.0.0.1" } else { "0.0.0.0" };
let world_host = if world_bind_port == 0 {
"127.0.0.1"
} else {
"0.0.0.0"
};
let world_listener =
TcpListener::bind(format!("{world_host}:{world_bind_port}")).map_err(|e| {
Error::Invalid(format!(
@@ -116,7 +124,9 @@ impl DirectIngress {
std::thread::Builder::new()
.name("direct-world-worker".into())
.spawn(move || {
if let Err(e) = handle_world_client(client_stream, local_world_port) {
if let Err(e) =
handle_world_client(client_stream, local_world_port)
{
eprintln!("handle_world_client error: {e}");
}
})
@@ -189,7 +199,9 @@ fn handle_auth_client(
}
}
let mut server = server.ok_or_else(|| {
Error::Invalid(format!("Cannot connect to local authserver: {local_auth_port}"))
Error::Invalid(format!(
"Cannot connect to local authserver: {local_auth_port}"
))
})?;
let _ = client.set_read_timeout(Some(Duration::from_millis(500)));
@@ -287,7 +299,9 @@ fn handle_world_client(mut client: TcpStream, local_world_port: u16) -> Result<(
}
}
let mut server = server.ok_or_else(|| {
Error::Invalid(format!("Cannot connect to local worldserver: {local_world_port}"))
Error::Invalid(format!(
"Cannot connect to local worldserver: {local_world_port}"
))
})?;
let _ = client.set_nonblocking(false);
+18 -3
View File
@@ -304,10 +304,25 @@ fn attempt_route_discovery(
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(coa_control_proto::relay::LOCAL_AUTH_PORT);
let local_world = std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT")
let local_world = match std::env::var("COA_OVERRIDE_LOCAL_WORLD_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or_else(|| service.local_world_port());
{
Some(p) => p,
None => match service.local_world_port() {
Ok(p) => p,
Err(e) => {
set_status(
status,
RouteStatus::RelayOnly {
reason: format!("Cannot resolve local world port: {e}"),
},
);
service.set_direct_route(None);
return;
}
},
};
let desired_auth_port: u16 = std::env::var("COA_DIRECT_AUTH_PORT")
.ok()
@@ -316,7 +331,7 @@ fn attempt_route_discovery(
let desired_world_port: u16 = std::env::var("COA_DIRECT_WORLD_PORT")
.ok()
.and_then(|p| p.parse().ok())
.unwrap_or(8085);
.unwrap_or(local_world);
let ingress = match DirectIngress::start(
desired_auth_port,
+21 -13
View File
@@ -95,19 +95,27 @@ impl HostControl {
store.clone(),
backend.clone(),
));
let relay_link =
crate::control::transport::relay_url(&base, "/relay/v1/host").map(
|r_url| {
let rl = crate::control::relay_link::RelayLink::start_with_ports(
r_url,
realm_id,
key.clone(),
Some(service.local_world_port()),
);
service.set_relay(rl.clone());
rl
},
);
let relay_link = match service.local_world_port() {
Ok(world_port) => {
crate::control::transport::relay_url(&base, "/relay/v1/host").map(
|r_url| {
let rl =
crate::control::relay_link::RelayLink::start_with_ports(
r_url,
realm_id,
key.clone(),
Some(world_port),
);
service.set_relay(rl.clone());
rl
},
)
}
Err(e) => {
eprintln!("Relay link unavailable for {local_id}: cannot resolve world port: {e}");
None
}
};
let direct_route =
Some(crate::control::direct_route::DirectRouteManager::start(
Some(base.clone()),
+11 -3
View File
@@ -319,9 +319,17 @@ 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::World => {
local_world_port.unwrap_or_else(|| target.local_port())
}
RelayTarget::World => match local_world_port {
Some(p) => p,
None => {
eprintln!("RelayLink cannot connect to World: local_world_port was not provided (fail-closed)");
let _ = ws_out_tx.send(TunnelMsg::ConnectErr {
stream_id,
error: "World port unresolved".into(),
});
continue;
}
},
};
match TcpStream::connect(("127.0.0.1", local_port)) {
Ok(tcp) => {
+15 -6
View File
@@ -120,10 +120,8 @@ pub trait RealmBackend: Send + Sync {
local_id: &str,
params: RemoteImportParams,
) -> Result<RemoteImportOutcome>;
/// Local world port for this realm.
fn world_port(&self, _local_id: &str) -> Result<u16> {
Ok(8085)
}
/// Local world port for this realm. Must fail closed if the realm port cannot be resolved.
fn world_port(&self, local_id: &str) -> Result<u16>;
}
/// State of one channel.
@@ -224,8 +222,8 @@ impl HostService {
self.realm_id
}
pub fn local_world_port(&self) -> u16 {
self.backend.world_port(&self.local_id).unwrap_or(8085)
pub fn local_world_port(&self) -> Result<u16> {
self.backend.world_port(&self.local_id)
}
/// The player's public key is the one recorded for this player id when there is a record; `None` when the player is new here.
@@ -1172,6 +1170,7 @@ pub mod fake {
pub seen_passwords: Mutex<Vec<String>>,
pub imported: Mutex<HashMap<Uuid, (u32, Uuid)>>,
pub incompatible: Mutex<std::collections::HashSet<Uuid>>,
pub world_port: Mutex<Option<Result<u16>>>,
}
impl FakeRealm {
@@ -1363,6 +1362,16 @@ pub mod fake {
notes: vec![],
})
}
fn world_port(&self, _: &str) -> Result<u16> {
if let Some(res) = self.world_port.lock().unwrap().as_ref() {
match res {
Ok(p) => Ok(*p),
Err(e) => Err(Error::Invalid(e.to_string())),
}
} else {
Ok(8085)
}
}
}
}
+4 -1
View File
@@ -201,7 +201,10 @@ impl ControlStore {
.map_err(db)?;
let _ = conn.pragma_update(None, "journal_mode", "WAL");
conn.execute_batch(SCHEMA).map_err(db)?;
let _ = conn.execute("ALTER TABLE host_transfer ADD COLUMN ruleset TEXT NOT NULL DEFAULT 'coa'", []);
let _ = conn.execute(
"ALTER TABLE host_transfer ADD COLUMN ruleset TEXT NOT NULL DEFAULT 'coa'",
[],
);
Ok(Self { conn })
}
@@ -1652,13 +1652,16 @@ impl PortableService {
if !super::super::session::protocol::carried_kind_for(kind, model.ruleset) {
return Err(fail(
"incompatible",
format!("Foreign collection kind {kind:?} in {} remote transfer", model.ruleset),
format!(
"Foreign collection kind {kind:?} in {} remote transfer",
model.ruleset
),
));
}
if let Ok(set) = super::super::collection::IdSet::from_ids(ids.iter().copied()) {
let _ =
self.host
.merge_collection(self.host_profile, model.ruleset, kind, &set);
let _ = self
.host
.merge_collection(self.host_profile, model.ruleset, kind, &set);
}
}
@@ -812,7 +812,10 @@ fn wildcard_and_coa_collections_remain_strictly_isolated() {
set: CompactSet::seal("wildcard:appearance", &wc_update).unwrap(),
};
let ack = o.handle_collection(&to_json(&msg_wc).unwrap()).unwrap();
assert!(matches!(ack.outcome, CollectionOutcome::Applied | CollectionOutcome::Unchanged));
assert!(matches!(
ack.outcome,
CollectionOutcome::Applied | CollectionOutcome::Unchanged
));
// CoA remains exactly [10, 20, 30]
let (_, s_coa_after) = o
+6 -2
View File
@@ -467,7 +467,9 @@ impl RealmBridge for FakeRealm {
impl FakeRealm {
fn rows(&self, kind: &str) -> Result<&IdSet> {
match kind {
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => Ok(&self.appearance_rows),
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => {
Ok(&self.appearance_rows)
}
"coa:vanity" | "wildcard:vanity" | "reborn:vanity" => Ok(&self.vanity_rows),
other => Err(PortableError::Invalid(format!("unknown kind {other}"))),
}
@@ -475,7 +477,9 @@ impl FakeRealm {
fn rows_mut(&mut self, kind: &str) -> Result<&mut IdSet> {
match kind {
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => Ok(&mut self.appearance_rows),
"coa:appearance" | "wildcard:appearance" | "reborn:appearance" => {
Ok(&mut self.appearance_rows)
}
"coa:vanity" | "wildcard:vanity" | "reborn:vanity" => Ok(&mut self.vanity_rows),
other => Err(PortableError::Invalid(format!("unknown kind {other}"))),
}
+15 -5
View File
@@ -1100,8 +1100,12 @@ fn wildcard_session_offer_carrying_coa_snapshot_is_rejected_with_zero_mutation()
let host_profile = host.default_profile().unwrap();
let coa_model = geared_level_eighty();
let id = owner.create_character(profile, coa_model, "realm-a").unwrap();
let mut offer = OwnerService::new(&mut owner).offer(id, "srv-1:wildcard").unwrap();
let id = owner
.create_character(profile, coa_model, "realm-a")
.unwrap();
let mut offer = OwnerService::new(&mut owner)
.offer(id, "srv-1:wildcard")
.unwrap();
// Tamper offer: claim it is Wildcard while snapshot is CoA
offer.ruleset = Ruleset::Wildcard;
@@ -1199,9 +1203,13 @@ fn wildcard_session_using_coa_server_id_is_rejected() {
let mut model = geared_level_eighty();
model.ruleset = Ruleset::Wildcard;
let id = owner.create_character(profile, model, "wildcard-origin").unwrap();
let id = owner
.create_character(profile, model, "wildcard-origin")
.unwrap();
// Offer for srv-1:coa
let offer = OwnerService::new(&mut owner).offer(id, "srv-1:coa").unwrap();
let offer = OwnerService::new(&mut owner)
.offer(id, "srv-1:coa")
.unwrap();
// Host configured for Wildcard realm
let mut h = HostService::new(&mut host, "srv-1:wildcard", HostConfig::default());
@@ -1216,7 +1224,9 @@ fn same_character_id_with_foreign_ruleset_is_rejected() {
let mut model = geared_level_eighty();
model.ruleset = Ruleset::Wildcard;
let id = owner.create_character(profile, model.clone(), "srv-1:wildcard").unwrap();
let id = owner
.create_character(profile, model.clone(), "srv-1:wildcard")
.unwrap();
// Tampered snapshot with same ID but Ruleset::Coa
let mut tampered = model.clone();
+95 -19
View File
@@ -2190,36 +2190,75 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_
let mut model_a = fixtures::geared_level_eighty();
model_a.identity.name = "Hero".into();
model_a.ruleset = Ruleset::Coa;
let char_a = store.create_character(profile, model_a, "srv-1:coa").unwrap();
let char_a = store
.create_character(profile, model_a, "srv-1:coa")
.unwrap();
// Character B: Wildcard
let mut model_b = fixtures::geared_level_eighty();
model_b.identity.name = "Hero".into();
model_b.ruleset = Ruleset::Wildcard;
let char_b = store.create_character(profile, model_b, "srv-1:wildcard").unwrap();
let char_b = store
.create_character(profile, model_b, "srv-1:wildcard")
.unwrap();
// Character C: Reborn
let mut model_c = fixtures::geared_level_eighty();
model_c.identity.name = "Hero".into();
model_c.ruleset = Ruleset::Reborn;
let char_c = store.create_character(profile, model_c, "srv-1:reborn").unwrap();
let char_c = store
.create_character(profile, model_c, "srv-1:reborn")
.unwrap();
// Initial identical mappings (local_guid = 100, item_guid = 200)
store.bind_server(char_a, "srv-1:coa", 100, 1, MappingState::Active).unwrap();
store.bind_server(char_b, "srv-1:wildcard", 100, 1, MappingState::Active).unwrap();
store.bind_server(char_c, "srv-1:reborn", 100, 1, MappingState::Active).unwrap();
store
.bind_server(char_a, "srv-1:coa", 100, 1, MappingState::Active)
.unwrap();
store
.bind_server(char_b, "srv-1:wildcard", 100, 1, MappingState::Active)
.unwrap();
store
.bind_server(char_c, "srv-1:reborn", 100, 1, MappingState::Active)
.unwrap();
let item_a = PortableItemId::new();
let item_b = PortableItemId::new();
let item_c = PortableItemId::new();
store.reconcile_item_mappings(char_a, "srv-1:coa", 1, &[obs(item_a, 200, 10, "e1")]).unwrap();
store.reconcile_item_mappings(char_b, "srv-1:wildcard", 1, &[obs(item_b, 200, 10, "e2")]).unwrap();
store.reconcile_item_mappings(char_c, "srv-1:reborn", 1, &[obs(item_c, 200, 10, "e3")]).unwrap();
store
.reconcile_item_mappings(char_a, "srv-1:coa", 1, &[obs(item_a, 200, 10, "e1")])
.unwrap();
store
.reconcile_item_mappings(char_b, "srv-1:wildcard", 1, &[obs(item_b, 200, 10, "e2")])
.unwrap();
store
.reconcile_item_mappings(char_c, "srv-1:reborn", 1, &[obs(item_c, 200, 10, "e3")])
.unwrap();
// Initial collections
store.merge_collection(profile, Ruleset::Coa, "coa:appearance", &IdSet::from_ids([1, 2, 3]).unwrap()).unwrap();
store.merge_collection(profile, Ruleset::Wildcard, "wildcard:appearance", &IdSet::from_ids([1, 2, 4]).unwrap()).unwrap();
store.merge_collection(profile, Ruleset::Reborn, "reborn:appearance", &IdSet::from_ids([1, 2, 5]).unwrap()).unwrap();
store
.merge_collection(
profile,
Ruleset::Coa,
"coa:appearance",
&IdSet::from_ids([1, 2, 3]).unwrap(),
)
.unwrap();
store
.merge_collection(
profile,
Ruleset::Wildcard,
"wildcard:appearance",
&IdSet::from_ids([1, 2, 4]).unwrap(),
)
.unwrap();
store
.merge_collection(
profile,
Ruleset::Reborn,
"reborn:appearance",
&IdSet::from_ids([1, 2, 5]).unwrap(),
)
.unwrap();
// Record baseline hashes and revisions
let hash_a_before = store.load_snapshot(char_a, 1).unwrap();
@@ -2229,26 +2268,60 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_
let mut model_b_mut = store.load_current(char_b).unwrap();
model_b_mut.progression.money += 100_000;
model_b_mut.progression.level = 80;
store.commit_snapshot(char_b, 1, model_b_mut, "srv-1:wildcard", Some("wildcard gameplay")).unwrap();
store
.commit_snapshot(
char_b,
1,
model_b_mut,
"srv-1:wildcard",
Some("wildcard gameplay"),
)
.unwrap();
store.merge_collection(profile, Ruleset::Wildcard, "wildcard:appearance", &IdSet::from_ids([1, 2, 4, 999]).unwrap()).unwrap();
store
.merge_collection(
profile,
Ruleset::Wildcard,
"wildcard:appearance",
&IdSet::from_ids([1, 2, 4, 999]).unwrap(),
)
.unwrap();
let new_item_b = PortableItemId::new();
store.reconcile_item_mappings(char_b, "srv-1:wildcard", 2, &[obs(item_b, 200, 10, "e2"), obs(new_item_b, 201, 10, "e2_new")]).unwrap();
store
.reconcile_item_mappings(
char_b,
"srv-1:wildcard",
2,
&[
obs(item_b, 200, 10, "e2"),
obs(new_item_b, 201, 10, "e2_new"),
],
)
.unwrap();
// ---- ASSERT WILDCARD B ADVANCED ----
let rec_b = store.character(char_b).unwrap();
assert_eq!(rec_b.revision, 2);
let (_, coll_b) = store.collection(profile, Ruleset::Wildcard, "wildcard:appearance").unwrap().unwrap();
let (_, coll_b) = store
.collection(profile, Ruleset::Wildcard, "wildcard:appearance")
.unwrap()
.unwrap();
assert_eq!(coll_b.ids(), &[1, 2, 4, 999]);
assert_eq!(store.item_mappings(char_b, "srv-1:wildcard").unwrap().len(), 2);
assert_eq!(
store.item_mappings(char_b, "srv-1:wildcard").unwrap().len(),
2
);
// ---- ASSERT COA A IS 100% UNTOUCHED ----
let rec_a = store.character(char_a).unwrap();
assert_eq!(rec_a.revision, 1);
let hash_a_after = store.load_snapshot(char_a, 1).unwrap();
assert_eq!(hash_a_before, hash_a_after);
let (_, coll_a) = store.collection(profile, Ruleset::Coa, "coa:appearance").unwrap().unwrap();
let (_, coll_a) = store
.collection(profile, Ruleset::Coa, "coa:appearance")
.unwrap()
.unwrap();
assert_eq!(coll_a.ids(), &[1, 2, 3]);
let items_a = store.item_mappings(char_a, "srv-1:coa").unwrap();
assert_eq!(items_a.len(), 1);
@@ -2260,7 +2333,10 @@ fn three_space_isolation_fixture_proves_wildcard_activity_leaves_coa_and_reborn_
assert_eq!(rec_c.revision, 1);
let hash_c_after = store.load_snapshot(char_c, 1).unwrap();
assert_eq!(hash_c_before, hash_c_after);
let (_, coll_c) = store.collection(profile, Ruleset::Reborn, "reborn:appearance").unwrap().unwrap();
let (_, coll_c) = store
.collection(profile, Ruleset::Reborn, "reborn:appearance")
.unwrap()
.unwrap();
assert_eq!(coll_c.ids(), &[1, 2, 5]);
let items_c = store.item_mappings(char_c, "srv-1:reborn").unwrap();
assert_eq!(items_c.len(), 1);
+20 -5
View File
@@ -130,12 +130,12 @@ pub fn find_access(
) -> Option<RealmAccess> {
let (base_id, explicit_mode) = if let Some((base, rs)) = local_id.split_once(':') {
let m = match rs {
"coa" => Some(crate::realms::Mode::Coa),
"wildcard" => Some(crate::realms::Mode::Wildcard),
"reborn" => Some(crate::realms::Mode::Reborn),
_ => None,
"coa" => crate::realms::Mode::Coa,
"wildcard" => crate::realms::Mode::Wildcard,
"reborn" => crate::realms::Mode::Reborn,
_ => return None,
};
(base, m)
(base, Some(m))
} else {
(local_id, None)
};
@@ -145,6 +145,21 @@ pub fn find_access(
{
let mut access = RealmAccess::from_install(id, root);
if let Some(m) = explicit_mode {
if let Ok(st) = crate::realms::state(root) {
let supported = match m {
crate::realms::Mode::Coa => {
st.active == crate::realms::Mode::Coa || st.simultaneous
}
crate::realms::Mode::Wildcard => {
st.active == crate::realms::Mode::Wildcard
|| (st.simultaneous && st.wildcard_created)
}
crate::realms::Mode::Reborn => st.active == crate::realms::Mode::Reborn,
};
if !supported {
return None;
}
}
access.id = format!("srv-{id}:{}", m.name());
access.name = format!("{} — {}", access.name, m.label());
access.mode = Some(m);
+12 -2
View File
@@ -749,11 +749,21 @@ pub fn world_port_for_mode(root: &Path, mode: Mode) -> Result<u16> {
(Mode::Coa, Mode::Wildcard) => second,
(Mode::Wildcard, Mode::Wildcard) => ports.world,
(Mode::Wildcard, Mode::Coa) => second,
(_, _) => ports.world,
_ => {
return Err(Error::Invalid(format!(
"Unsupported mode {mode:?} for simultaneous hosting (active: {:?})",
s.active
)))
}
};
Ok(port)
} else {
} else if mode == s.active {
Ok(ports.world)
} else {
Err(Error::Invalid(format!(
"Realm mode {mode:?} is not active (active: {:?}) and simultaneous mode is disabled.",
s.active
)))
}
}
+37 -46
View File
@@ -53,9 +53,41 @@ Status: Updated for MR2 Gate (Wildcard Vertical Slice Completed)
---
## 2. Inventory of Current Subsystem Locations & Representation
## 2. Current Implementation Architecture & Historical Baseline Inventory
### 2.1 Realm Profiles & Databases (`crates/coa-core/src/realms.rs`, `db.rs`)
### 2.1 Current Implementation State (As of MR1, MR1.1, MR2, MR2.1)
- **Authoritative Ruleset Representation:**
- `crates/coa-core/src/portable/model.rs`: `enum Ruleset { Coa, Wildcard, Reborn }` (strict parsing and schema routing without fallbacks).
- `crates/coa-registry-proto/src/caps.rs`: `enum Ruleset { Coa, Wildcard, Reborn }`.
- `services/coa-registry/src/pg.rs`: `ruleset: match ruleset.as_str() { "coa" => Ruleset::Coa, "wildcard" => Ruleset::Wildcard, "reborn" => Ruleset::Reborn, ... }`.
- `src/lib/browse.ts`: `export type Ruleset = "coa" | "wildcard" | "reborn";`.
- `crates/coa-core/src/realms.rs`: `enum Mode { Coa, Wildcard, Reborn }`.
- **Wire Protocols & Ruleset Requirements:**
- **Control Protocol 2:** `CONTROL_PROTOCOL_VERSION = 2`. `Request::TransferOffer` requires explicit, non-default `ruleset: String`. `Response::Claimed` returns explicit `ruleset: String`. `HostTransfer` records ruleset.
- **Session Protocol 3:** `SESSION_PROTOCOL_VERSION = 3`. `SessionOffer`, `PortableSessionStarted`, `PortableCheckpoint`, and `OwnerAck` require explicit, mandatory `ruleset: Ruleset`.
- **Collection Protocol 2:** `COLLECTION_PROTOCOL_VERSION = 2`. `CollectionObserved`, `CollectionState`, and `CollectionAck` require explicit, mandatory `ruleset: Ruleset`.
- **Database Routing & Fail-Closed Invariants:**
- `LocalBackend::info()` and `LocalBackend::access()` strictly enforce:
$$\text{local\_id ruleset} == \text{RealmAccess.mode} == \text{Db.realm()} == \text{RealmInfo.ruleset}$$
Any mismatch or malformed/unknown ruleset suffix returns an immediate error.
- `LocalBackend::world_port()` and `crates/coa-core/src/realms::world_port_for_mode()` return fallible `Result<u16>` without silent fallbacks to 8085. If a realm is not running or not simultaneous, port resolution fails closed and disables Direct and Relay routing for that realm.
- **Canonical Storage (`portable.db`):**
- Transactional SQLite Migration 009 upgraded `character` table constraint to `CHECK (ruleset IN ('coa', 'wildcard', 'reborn'))` while preserving rows byte-for-byte.
- `collection` table is partitioned by `(profile_id, ruleset, kind)`.
- Mappings (`character_server_mapping`, `item_mapping`, `pet_mapping`, sessions) isolate distinct server IDs (`srv-X:coa`, `srv-X:wildcard`, `srv-X:reborn`).
### 2.2 Historical Baseline Inventory (Pre-Implementation State as of v0.7.0 Master)
> [!NOTE]
> The following findings reflect the legacy v0.7.0 baseline discovered before MR1/MR2 implementation and are preserved for historical reference:
- *Historical:* `Ruleset` enum in `model.rs` and `caps.rs` lacked `Reborn`.
- *Historical:* `character` table had `CHECK (ruleset IN ('coa', 'wildcard'))`, causing SQLite constraint failures on Reborn.
- *Historical:* `collection` table was keyed only by `(profile_id, kind)` without `ruleset`.
- *Historical:* `TransferOffer`, `PortableSessionStarted`, `PortableCheckpoint`, and `CollectionState` lacked explicit protocol-level `ruleset` fields.
- *Historical:* `LocalBackend::info()` had an unsafe fallback `ruleset_of(db).unwrap_or(Ruleset::Coa)`.
- *Historical:* `world_port_for_mode()` fell back to `ports.world` (8085) even when Wildcard was inactive.
- *Historical:* `Ruleset::schema(kind)` had `_ => "acore_characters"`.
### 2.3 Realm Profiles & Databases (`crates/coa-core/src/realms.rs`, `db.rs`)
| Ruleset / Mode | Realm ID | Character Database | World Database | Auth Database | Configuration Profile |
|---|---|---|---|---|---|
| **CoA** | `1` | `acore_characters` | `acore_world` | `acore_auth` | `Settings/realm-profiles/coa.json` |
@@ -70,52 +102,11 @@ Status: Updated for MR2 Gate (Wildcard Vertical Slice Completed)
- Wildcard detected by byte pattern: `b"Wildcard synergy settings"`.
- Reborn detected by byte pattern: `b"Warcraft Reborn class spells are unavailable"`.
### 2.2 Ruleset Enumeration & Protocols
- `crates/coa-core/src/realms.rs`: `enum Mode { Coa, Wildcard, Reborn }`
- `crates/coa-core/src/portable/model.rs`: `enum Ruleset { Coa, Wildcard }` *(Lacks `Reborn`)*
- `crates/coa-registry-proto/src/caps.rs`: `enum Ruleset { Coa, Wildcard }` *(Lacks `Reborn`)*
- `services/coa-registry/src/pg.rs`: `ruleset: match ruleset.as_str() { "coa" => Ruleset::Coa, "wildcard" => Ruleset::Wildcard }` *(Lacks `Reborn`)*
- `src/lib/browse.ts`: `export type Ruleset = "coa" | "wildcard";` *(Lacks `reborn`)*
### 2.3 Public Registry Model & Publication
### 2.4 Public Registry Model & Multi-Realm Publication
- `crates/coa-core/src/realm_registry/settings.rs`: `RegistrySettings.realms` is a `BTreeMap<String, PublishConfig>`.
- `src/screens/PublishCard.tsx`: Uses `localId = 'srv-' + serverId`.
- **Host vs Published Realm Identity Issue:**
One installation (`InstallMeta.id`) hosting multiple rulesets currently shares `localId = srv-<id>`. If the host switches active realm from CoA to Wildcard, or publishes both under simultaneous mode, `srv-<id>` cannot represent both published realms distinctly.
Each published realm must possess its own distinct local realm identity (`srv-<id>-<ruleset>` or `srv-<id>:<ruleset>`), unique `RealmId` (UUIDv7), and independent Ed25519 signing key in `KeyStore`.
### 2.4 Canonical Portable Storage (`crates/coa-core/src/portable/store.rs`)
- SQLite schema (`%LOCALAPPDATA%\CoAServerManager\portable\portable.db`):
- `character`: `ruleset TEXT NOT NULL CHECK (ruleset IN ('coa', 'wildcard'))` -> Must be updated to allow `'reborn'`.
- `collection`: `PRIMARY KEY (profile_id, kind)` -> Unpartitioned by ruleset! Must become `(profile_id, ruleset, kind)`.
- `character_server_mapping`: `PRIMARY KEY (character_id, server_id), UNIQUE (server_id, local_guid)`.
- `item_mapping`: `PRIMARY KEY (character_id, server_id, portable_item_id)`.
- `pet_mapping`: `PRIMARY KEY (character_id, server_id, portable_pet_id)`.
- `owner_session`: `character_id`, `server_id`, `c0_revision`, `b0_hash`.
- `host_session`: `character_id`, `server_id`, `local_guid`.
### 2.5 Remote Transfer & Control Protocol (`crates/coa-control-proto/src/app.rs`)
- `Request::TransferOffer`:
`{ transfer_id, character_id, canonical_revision, content_hash, total_size, collections }`
-> **Missing `ruleset`**. An offer must bind `ruleset: Ruleset` so the host can reject before chunk transport or preflight if target realm mismatch occurs.
- `Request::TransferCommit`:
Currently relies on cached transfer metadata.
- `Response::TransferCommitted`:
Returns session parameters.
### 2.6 Runtime Sessions & Checkpoints (`crates/coa-core/src/portable/session/protocol.rs`)
- `PortableSessionStarted`: `{ protocol_version, session_id, character_id, server_id, base_canonical_revision, baseline_generation, b0, content_hash, progression }`
-> **Missing `ruleset`**.
- `PortableCheckpoint`: `{ protocol_version, session_id, character_id, server_id, base_canonical_revision, sequence, final_checkpoint, realm_snapshot, content_hash, pin }`
-> **Missing `ruleset`**.
- `OwnerAck`: `{ protocol_version, session_id, sequence, outcome, canonical_revision, canonical_hash, owned_items, owned_pets, canonical, next_session, pin }`
-> Must verify ruleset match in owner session state before applying 3-way merge.
### 2.7 Direct & Relay Routing
- **Direct Ingress Proxy (`crates/coa-core/src/control/direct_ingress.rs`):**
Listens on direct auth and world ports, intercepts `CMD_REALM_LIST` from the Auth server, and rewrites the realm entry address to the public direct world IP and port. When multiple realms are active (simultaneous CoA + Wildcard), each published realm must target its respective local world port (`ports.world` vs `s.secondary_world_port`).
- **Relay Hub & Allocations (`services/coa-relay/`, `crates/coa-core/src/control/relay_link.rs`):**
Relay allocation requests are authenticated by Ed25519 realm key. Allocations bind a short-lived token to an isolated auth/world stream pair.
- **Multi-Realm Publication on One Host:**
Each published realm possesses its own distinct local realm identity (`srv-<id>:<ruleset>`), unique `RealmId` (UUIDv7), and independent Ed25519 signing key in `KeyStore`. Direct Ingress proxy and Relay links dynamically route to the respective realm's active world port.
---
+14 -8
View File
@@ -4,10 +4,16 @@ Overview offers CoA and Wildcard. The installed worldserver must contain Wildcar
already have its Wildcard migrations. The Manager refuses an unsupported binary, an occupied realm ID 2 or an
existing destination database rather than overwrite them.
The first switch copies the CoA world into `acore_world_wildcard` and creates `acore_characters_wildcard` from
the character schema and neutral server seed tables. Existing characters, card collections and progress are not
copied. Accounts remain in shared `acore_auth`. Realm IDs 1 and 2 share the world port; only the selected world
runs, and the inactive realm is listed offline. A switch while running stops the server gracefully and restarts it.
CoA and Wildcard have separate character and world databases (`acore_characters` / `acore_world` for CoA, and
`acore_characters_wildcard` / `acore_world_wildcard` for Wildcard). Accounts remain in shared `acore_auth`.
When simultaneous realm execution is enabled in configuration (`simultaneous: true`), both CoA and Wildcard
realms can run concurrently:
- Primary and secondary world ports (`ports.world` and `secondary_world_port`, e.g. 8085 and 8086) and RA ports
are distinct.
- Remote routing (both Direct Ingress and Relay links) is strictly bound to the specific ruleset realm selected:
routing to a Wildcard realm targets the Wildcard world port and never advertises or connects to the CoA port.
- When simultaneous execution is disabled, only the currently active realm runs and provides world service.
Configuration profiles are saved under `Settings/realm-profiles`. An interrupted switch can restore its previous
configuration from the journal. CoA companion bots cannot be enabled on Wildcard. Content Scaling and the other
@@ -21,12 +27,12 @@ adapter before the next start.
## Portable Characters on Wildcard
As of v0.7.1 (MR2), Wildcard realms fully participate in the Portable Characters subsystem with strict multi-ruleset isolation:
As of v0.7.1, Wildcard realms participate in the Portable Characters subsystem with strict multi-ruleset isolation:
- **Canonical Model:** Wildcard portable characters carry `ruleset: "wildcard"` and `content_namespace: "wildcard"`.
- **Claim & Export:** Real characters played on a Wildcard realm (`acore_characters_wildcard`) can be claimed and exported into canonical portable snapshots. Wildcard character builds—including spells, talents, skills, action bars, settings, inventory, pets, and currencies—are fully captured.
- **Import & Session Runtime:** A Wildcard portable character can be transferred to a remote Wildcard host, imported into its `acore_characters_wildcard` database, armed in a runtime session (Session Protocol 3), and played live with automatic checkpoints and logout synchronization.
- **Claim & Export:** Characters played on a Wildcard realm (`acore_characters_wildcard`) can be claimed and exported into canonical portable snapshots. Wildcard character builds—including spells, talents, skills, action bars, settings, inventory, pets, and currencies—are preserved.
- **Import & Session Runtime:** A Wildcard portable character can be transferred to a remote Wildcard host, imported into its `acore_characters_wildcard` database, armed in a runtime session (Session Protocol 3), and tracked through checkpoints and logout synchronization.
- **Collections:** Wildcard collections (`wildcard:appearance` and `wildcard:vanity`) are partitioned separately from CoA collections. Unlocks on a Wildcard realm union into the profile's Wildcard collections and never touch CoA collections.
- **Fail-Closed Protection:** Any attempt to transfer or import a CoA character into a Wildcard realm, or vice versa, is rejected at the protocol and database boundaries with zero mutation.
- **Fail-Closed Protection:** Any attempt to transfer or import a CoA character into a Wildcard realm, or vice versa, is rejected at the protocol and database boundaries with zero mutation. If world-port resolution fails or a realm mode is inactive, Direct and Relay routing fail closed.
![Wildcard overview](screenshots/wildcard-demo/overview.jpg)