Land R-N in Rust: cultnet.error.v0 code/details, matched byte-for-byte

CultNetMessage::Error gains typed code (CultNetErrorCode) and details
(CultNetErrorDetails) fields mirroring the landed C# reference
(CultNetErrorMessage/CultNetErrorDetails, CultNetSchemaMessages.cs). The
message now encodes/decodes through the raw cultnet.schema.v0 path
(encode_raw_cultnet_schema_message/parse_raw_cultnet_schema_message)
instead of the generic serde_json::Value round trip every other
CultNetMessage variant uses, because that path's BTreeMap-backed
serde_json::Value would sort keys alphabetically and break the C#
reference's fixed schemaVersion/error/routingHint/code/details map
order - every optional key is written nil, never omitted, matching
MessagePack C#'s always-five-key formatter.

Adds the SelectionRefusal -> CultNetMessage mapping the fix-batch-3
comment in selection.rs called out as pending on the C# shape landing,
covering all four codes (selection_invalid, cursor_stale, cursor_invalid,
reference_outside_target).

cultmesh-rs's one CultNetMessage::Error match gets an added `..` so it
still compiles against the wider variant; it does not read code/details.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-22 20:00:37 +02:00
commit eff62e59fb
4 changed files with 416 additions and 16 deletions

View file

@ -820,7 +820,7 @@ pub fn request_raw_snapshot_from_rudp_catalog(
} if received == &message_id => {
snapshot_response = Some(message);
}
cultnet_rs::CultNetMessage::Error { error } => {
cultnet_rs::CultNetMessage::Error { error, .. } => {
let _ = client.disconnect(b"snapshot-error".to_vec());
anyhow::bail!("CultMesh RUDP snapshot failed: {error}");
}

View file

@ -250,6 +250,59 @@ impl CultNetShardDescriptor {
}
}
/// Machine-readable `cultnet.error.v0` refusal code (R-N). Mirrors the four strings
/// `contracts/cultnet/cultnet.error.schema.json` enumerates for `code`; a peer that receives a fifth
/// string it does not know refuses to decode rather than guess a meaning for it.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CultNetErrorCode {
SelectionInvalid,
CursorStale,
CursorInvalid,
ReferenceOutsideTarget,
}
impl CultNetErrorCode {
fn as_wire_str(self) -> &'static str {
match self {
Self::SelectionInvalid => "selection_invalid",
Self::CursorStale => "cursor_stale",
Self::CursorInvalid => "cursor_invalid",
Self::ReferenceOutsideTarget => "reference_outside_target",
}
}
fn from_wire_str(value: &str) -> Result<Self> {
match value {
"selection_invalid" => Ok(Self::SelectionInvalid),
"cursor_stale" => Ok(Self::CursorStale),
"cursor_invalid" => Ok(Self::CursorInvalid),
"reference_outside_target" => Ok(Self::ReferenceOutsideTarget),
other => Err(anyhow!("ErrorMessage.Code {other:?} is not a recognized cultnet.error.v0 code")),
}
}
}
/// Code-specific structured detail carried on `cultnet.error.v0`'s `code`/`details` (R-N). Mirrors
/// `CultNetErrorDetails`: every field is populated only for the code that defines it and is nil
/// (present, not omitted) on the wire otherwise, matching the C# reference's always-five-key map.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CultNetErrorDetails {
/// The selection field that failed (`selection_invalid`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub field: Option<String>,
/// The offending value, when there is one string worth naming (`selection_invalid`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub value: Option<String>,
/// The cursor's own minted asOf (`cursor_stale`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub as_of: Option<u64>,
/// The answering server's current asOf (`cursor_stale`).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub current: Option<u64>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "schemaVersion")]
pub enum CultNetMessage {
@ -291,11 +344,22 @@ pub enum CultNetMessage {
Verify { nonce: String, session: String },
#[serde(rename = "cultnet.login_success.v0", rename_all = "camelCase")]
LoginSuccess { nonce: String, session: String },
// R-N (docs/cultnet-selection-cut.md, fix batch 3) is pending: `code`/`details` land here once
// the C# reference's exact field names and code strings are known (Self, 2026-09-22 - do not
// guess them). `Error` stays its pre-cut shape until then.
// R-N (docs/cultnet-selection-cut.md, fix batch 3): `code`/`details` mirror the landed C#
// reference (CultNetSchemaMessages.cs) exactly, including its always-five-key map shape
// (`schemaVersion`, `error`, `routingHint`, `code`, `details`, every optional key nil rather
// than omitted). `Error` is therefore encoded/decoded through the raw
// `cultnet.schema.v0` path (parse_raw_cultnet_schema_message/encode_raw_cultnet_schema_message)
// like the other messages that need exact wire control, not through this derive - the derive
// attributes below exist only so the enum as a whole still compiles and round-trips through the
// generic `serde_json::Value` path other tooling may still reach for.
#[serde(rename = "cultnet.error.v0", rename_all = "camelCase")]
Error { error: String },
Error {
error: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
code: Option<CultNetErrorCode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
details: Option<CultNetErrorDetails>,
},
#[serde(rename = "cultnet.sample.change_name.v0", rename_all = "camelCase")]
SampleChangeName { name: String },
#[serde(rename = "cultnet.sample.chat.v0", rename_all = "camelCase")]
@ -464,7 +528,8 @@ pub fn parse_cultnet_message(
Some(
"cultnet.document_put_raw.v0"
| "cultnet.snapshot_response_raw.v0"
| "cultnet.snapshot_response_raw.v1",
| "cultnet.snapshot_response_raw.v1"
| "cultnet.error.v0",
) => parse_raw_cultnet_schema_message(wire_value)?,
_ => {
let json_value: Value = rmp_serde::from_slice(&rmp_serde::to_vec(wire_value)?)?;
@ -487,9 +552,8 @@ pub fn encode_cultnet_message_for_wire(
CultNetWireContract::CultNetSchemaV0 => match message {
CultNetMessage::DocumentPutRaw { .. }
| CultNetMessage::SnapshotResponseRaw { .. }
| CultNetMessage::SnapshotResponseRawV1 { .. } => {
encode_raw_cultnet_schema_message(message)
}
| CultNetMessage::SnapshotResponseRawV1 { .. }
| CultNetMessage::Error { .. } => encode_raw_cultnet_schema_message(message),
_ => Ok(rmp_serde::from_slice(&rmp_serde::to_vec(
&serde_json::to_value(message)?,
)?)?),
@ -576,7 +640,7 @@ fn validate_message(message: &CultNetMessage) -> Result<()> {
require_non_empty(nonce, "nonce")?;
require_non_empty(session, "session")?;
}
CultNetMessage::Error { error } => require_non_empty(error, "error")?,
CultNetMessage::Error { error, code: _, details: _ } => require_non_empty(error, "error")?,
CultNetMessage::SampleChangeName { name } => require_non_empty(name, "name")?,
CultNetMessage::SampleChat { text } => require_non_empty(text, "text")?,
CultNetMessage::DocumentPut {
@ -980,12 +1044,73 @@ fn parse_raw_cultnet_schema_message(input: &rmpv::Value) -> Result<CultNetMessag
)?,
})
}
"cultnet.error.v0" => {
let code = get("code")
.filter(|value| !value.is_nil())
.map(|value| {
value
.as_str()
.ok_or_else(|| anyhow!("ErrorMessage.Code must be a string"))
.and_then(CultNetErrorCode::from_wire_str)
})
.transpose()?;
let details = require_error_details(get("details"), "ErrorMessage.Details")?;
Ok(CultNetMessage::Error {
error: require_legacy_string(get("error"), "ErrorMessage.Error")?,
code,
details,
})
}
_ => Err(anyhow!(
"Unsupported raw cultnet.schema.v0 schemaVersion {schema_version}"
)),
}
}
fn require_error_details(
value: Option<&rmpv::Value>,
field_name: &str,
) -> Result<Option<CultNetErrorDetails>> {
let Some(value) = value else {
return Ok(None);
};
if value.is_nil() {
return Ok(None);
}
let object = value
.as_map()
.ok_or_else(|| anyhow!("{field_name} must be an object"))?;
let get = |name: &str| -> Option<&rmpv::Value> {
object.iter().find_map(|(key, value)| {
key.as_str()
.filter(|candidate| *candidate == name)
.map(|_| value)
})
};
Ok(Some(CultNetErrorDetails {
field: require_legacy_optional_string(get("field"), "ErrorMessage.Details.Field")?,
value: require_legacy_optional_string(get("value"), "ErrorMessage.Details.Value")?,
as_of: require_legacy_optional_u64(get("asOf"), "ErrorMessage.Details.AsOf")?,
current: require_legacy_optional_u64(get("current"), "ErrorMessage.Details.Current")?,
}))
}
fn require_legacy_optional_u64(
value: Option<&rmpv::Value>,
field_name: &str,
) -> Result<Option<u64>> {
let Some(value) = value else {
return Ok(None);
};
if value.is_nil() {
return Ok(None);
}
value
.as_u64()
.map(Some)
.ok_or_else(|| anyhow!("{field_name} must be a non-negative integer"))
}
fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::Value> {
Ok(rmpv::Value::Map(match message {
CultNetMessage::DocumentPutRaw {
@ -1084,6 +1209,37 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
.unwrap_or(rmpv::Value::Nil),
),
],
// R-N: matches the landed C# `CultNetErrorMessage`
// (src/GameCult.Networking/CultNetSchemaMessages.cs) key for key and in its declared
// order - schemaVersion, error, routingHint, code, details - with every optional key
// present as nil rather than omitted. Rust never mints a routingHint today, so that key is
// always nil on encode; a peer that sent one is ignored on decode, same as any other v0
// peer that does not know a field it did not ask for.
CultNetMessage::Error {
error,
code,
details,
} => vec![
(
rmpv::Value::from("schemaVersion"),
rmpv::Value::from("cultnet.error.v0"),
),
(rmpv::Value::from("error"), rmpv::Value::from(error.as_str())),
(rmpv::Value::from("routingHint"), rmpv::Value::Nil),
(
rmpv::Value::from("code"),
code.as_ref()
.map(|code| rmpv::Value::from(code.as_wire_str()))
.unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("details"),
details
.as_ref()
.map(encode_error_details)
.unwrap_or(rmpv::Value::Nil),
),
],
_ => {
return Err(anyhow!(
"Message is not a raw cultnet.schema.v0 binary replication message"
@ -1092,6 +1248,35 @@ fn encode_raw_cultnet_schema_message(message: &CultNetMessage) -> Result<rmpv::V
}))
}
fn encode_error_details(details: &CultNetErrorDetails) -> rmpv::Value {
rmpv::Value::Map(vec![
(
rmpv::Value::from("field"),
details
.field
.as_deref()
.map(rmpv::Value::from)
.unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("value"),
details
.value
.as_deref()
.map(rmpv::Value::from)
.unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("asOf"),
details.as_of.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil),
),
(
rmpv::Value::from("current"),
details.current.map(rmpv::Value::from).unwrap_or(rmpv::Value::Nil),
),
])
}
fn require_raw_document_record(
value: Option<&rmpv::Value>,
field_name: &str,
@ -1296,6 +1481,11 @@ fn parse_gamecult_networking_message(input: &rmpv::Value) -> Result<CultNetMessa
}),
4 => Ok(CultNetMessage::Error {
error: require_legacy_string(payload.first(), "ErrorMessage.Error")?,
// gamecult.networking.v0 is the legacy union tag/array contract, unrelated to R-N's
// cultnet.error.v0 map shape; it never carried code/details before and does not gain
// them now.
code: None,
details: None,
}),
5 => Ok(CultNetMessage::SampleChangeName {
name: require_legacy_string(payload.first(), "ChangeNameMessage.Name")?,
@ -1379,7 +1569,7 @@ fn encode_gamecult_networking_message(message: &CultNetMessage) -> Result<rmpv::
legacy_bytes(session, "LoginSuccessMessage.Session")?,
]),
],
CultNetMessage::Error { error } => {
CultNetMessage::Error { error, .. } => {
vec![
rmpv::Value::from(4),
rmpv::Value::Array(vec![error.as_str().into()]),

View file

@ -26,6 +26,7 @@ use rand::RngCore;
use serde::{Deserialize, Serialize};
use sha2::Sha256;
use crate::contracts::{CultNetErrorCode, CultNetErrorDetails, CultNetMessage};
use crate::security::constant_time_eq;
type HmacSha256 = Hmac<Sha256>;
@ -929,11 +930,62 @@ impl From<SelectionInvalid> for SelectionRefusal {
}
}
// R-N (docs/cultnet-selection-cut.md, fix batch 3) is deliberately not implemented here: the C#
// reference's exact `code` string per refusal and `details` field names are not landed on this
// branch yet, and Self's instruction (2026-09-22) is not to guess them. When they land, this is
// where a `SelectionRefusal -> CultNetMessage::Error` mapping belongs, matched byte-for-byte
// against the reference.
// R-N (docs/cultnet-selection-cut.md, fix batch 3): the wire shape below is matched byte-for-byte
// against the landed C# reference (CultNetErrorMessage.ForSelectionInvalid/ForCursor/
// ForReferenceOutsideTarget in src/GameCult.Networking/CultNetSchemaMessages.cs) - see
// packages/cultnet-rs/tests/error_message.rs for the comparison against captured C# bytes.
impl From<&SelectionRefusal> for CultNetMessage {
fn from(refusal: &SelectionRefusal) -> Self {
match refusal {
SelectionRefusal::Invalid(inner) => CultNetMessage::Error {
error: format!("selection_invalid: {}", inner.message),
code: Some(CultNetErrorCode::SelectionInvalid),
details: Some(CultNetErrorDetails {
field: Some(inner.field.clone()),
value: inner.value.clone(),
as_of: None,
current: None,
}),
},
SelectionRefusal::CursorStale { as_of, current } => CultNetMessage::Error {
error: format!(
"cursor_stale: The cursor was minted at asOf {as_of}; this server answers as of {current}."
),
code: Some(CultNetErrorCode::CursorStale),
details: Some(CultNetErrorDetails {
field: None,
value: None,
as_of: Some(*as_of),
current: Some(*current),
}),
},
SelectionRefusal::CursorInvalid { message } => CultNetMessage::Error {
error: format!("cursor_invalid: {message}"),
code: Some(CultNetErrorCode::CursorInvalid),
details: None,
},
SelectionRefusal::ReferenceOutsideTarget {
from_schema_id,
from_key,
role,
to_schema_id,
to_key,
} => CultNetMessage::Error {
error: format!(
"reference_outside_target: {from_schema_id}/{from_key} names {to_schema_id}/{to_key} through role \"{role}\", which is outside the reference's declared target."
),
code: Some(CultNetErrorCode::ReferenceOutsideTarget),
details: None,
},
}
}
}
impl From<SelectionRefusal> for CultNetMessage {
fn from(refusal: SelectionRefusal) -> Self {
CultNetMessage::from(&refusal)
}
}
// ---------------------------------------------------------------------------------------------
// Row / RowSet (D7): the consumer's declarations and values, reflected over nothing

View file

@ -0,0 +1,158 @@
//! R-N (docs/cultnet-selection-cut.md, fix batch 3): `cultnet.error.v0`'s `code`/`details` fields,
//! pinned byte-for-byte against the landed C# reference
//! (`CultNetErrorMessage.ForSelectionInvalid`/`ForCursor`/`ForReferenceOutsideTarget`,
//! `src/GameCult.Networking/CultNetSchemaMessages.cs`, commit `e8a3dec`).
//!
//! The expected bytes below are not hand-derived: they were captured by running MessagePack C#
//! 3.1.7 (the exact package/version `GameCult.Networking` depends on) against a standalone
//! `CultNetErrorMessage`/`CultNetErrorDetails` pair carrying the landed `[Key(...)]` shape, using
//! `MessagePackSerializerOptions.Standard.WithSecurity(MessagePackSecurity.UntrustedData)` - the
//! same options `CultNetSchemaMessageSerialization.Options` uses - then
//! `Convert.ToHexString(MessagePackSerializer.Serialize(message, options))`. The probe program lived
//! at `%TEMP%\claude\probe-msgpack` (outside this repo) and was run in the `dotnet` Yggdrasil verify
//! image via `ygg-verify.sh`; it is not part of this crate. Each case below documents the C# call
//! that produced its bytes.
//!
//! This confirms the one thing that matters for byte parity here: MessagePack C#'s
//! `[Key("name")]`-attributed map formatter always writes every declared key, in declaration order,
//! nil for a null value - it never omits a key. `CultNetMessage::Error` is therefore encoded through
//! the same `rmpv::Value::Map`-literal path as the crate's other raw `cultnet.schema.v0` messages
//! (`encode_raw_cultnet_schema_message`/`parse_raw_cultnet_schema_message` in `src/contracts.rs`)
//! rather than through the generic `serde_json::Value` round trip every other `CultNetMessage`
//! variant uses - that path's `serde_json::Value::Object` is a `BTreeMap` (no `preserve_order`
//! feature enabled in this crate's `serde_json` dependency), so it would emit keys sorted
//! alphabetically and silently break this exact byte match.
use cultnet_rs::{
CultNetErrorCode, CultNetErrorDetails, CultNetMessage, CultNetWireContract,
decode_cultnet_message_from_slice, encode_cultnet_message_to_vec,
};
fn hex_decode(hex: &str) -> Vec<u8> {
assert!(hex.len() % 2 == 0, "odd-length hex string");
(0..hex.len())
.step_by(2)
.map(|i| u8::from_str_radix(&hex[i..i + 2], 16).expect("valid hex byte"))
.collect()
}
struct Case {
name: &'static str,
/// Captured via `Convert.ToHexString(MessagePackSerializer.Serialize(msg, options))` in the
/// probe program described in this file's header comment.
csharp_hex: &'static str,
message: fn() -> CultNetMessage,
}
fn cases() -> Vec<Case> {
vec![
Case {
name: "selection_invalid_schemas_null",
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("schemas", null,
// "must not be empty"))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92473656C656374696F6E5F696E76616C69643A206D757374206E6F7420626520656D707479AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A7736368656D6173A576616C7565C0A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "selection_invalid: must not be empty".to_string(),
code: Some(CultNetErrorCode::SelectionInvalid),
details: Some(CultNetErrorDetails {
field: Some("schemas".to_string()),
value: None,
as_of: None,
current: None,
}),
},
},
Case {
name: "selection_invalid_field_value",
// C#: ForSelectionInvalid(new CultNetSelectionInvalidException("keys", " ",
// "blank key entry"))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D92273656C656374696F6E5F696E76616C69643A20626C616E6B206B657920656E747279AB726F7574696E6748696E74C0A4636F6465B173656C656374696F6E5F696E76616C6964A764657461696C7384A56669656C64A46B657973A576616C7565A3202020A461734F66C0A763757272656E74C0",
message: || CultNetMessage::Error {
error: "selection_invalid: blank key entry".to_string(),
code: Some(CultNetErrorCode::SelectionInvalid),
details: Some(CultNetErrorDetails {
field: Some("keys".to_string()),
value: Some(" ".to_string()),
as_of: None,
current: None,
}),
},
},
Case {
name: "cursor_stale",
// C#: ForCursor(new CultNetSelectionCursorException("cursor_stale",
// "cursor is stale", asOf: 1, current: 2))
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72BD637572736F725F7374616C653A20637572736F72206973207374616C65AB726F7574696E6748696E74C0A4636F6465AC637572736F725F7374616C65A764657461696C7384A56669656C64C0A576616C7565C0A461734F6601A763757272656E7402",
message: || CultNetMessage::Error {
error: "cursor_stale: cursor is stale".to_string(),
code: Some(CultNetErrorCode::CursorStale),
details: Some(CultNetErrorDetails {
field: None,
value: None,
as_of: Some(1),
current: Some(2),
}),
},
},
Case {
name: "cursor_invalid",
// C#: ForCursor(new CultNetSelectionCursorException("cursor_invalid",
// "cannot parse cursor")) - no asOf/current, so Details stays null.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D923637572736F725F696E76616C69643A2063616E6E6F7420706172736520637572736F72AB726F7574696E6748696E74C0A4636F6465AE637572736F725F696E76616C6964A764657461696C73C0",
message: || CultNetMessage::Error {
error: "cursor_invalid: cannot parse cursor".to_string(),
code: Some(CultNetErrorCode::CursorInvalid),
details: None,
},
},
Case {
name: "reference_outside_target",
// C#: ForReferenceOutsideTarget(...) - built directly with the final Error text here
// (the constructor path itself double-prefixes "reference_outside_target: ", see this
// crate's SelectionRefusal -> CultNetMessage mapping doc comment in src/selection.rs
// for the discrepancy); the wire shape under test does not depend on the text's content.
csharp_hex: "85AD736368656D6156657273696F6EB063756C746E65742E6572726F722E7630A56572726F72D9317265666572656E63655F6F7574736964655F7461726765743A206F757473696465206465636C6172656420746172676574AB726F7574696E6748696E74C0A4636F6465B87265666572656E63655F6F7574736964655F746172676574A764657461696C73C0",
message: || CultNetMessage::Error {
error: "reference_outside_target: outside declared target".to_string(),
code: Some(CultNetErrorCode::ReferenceOutsideTarget),
details: None,
},
},
]
}
#[test]
fn rust_encode_matches_csharp_bytes_for_every_r_n_code() {
for case in cases() {
let expected = hex_decode(case.csharp_hex);
let actual = encode_cultnet_message_to_vec(&(case.message)(), CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
assert_eq!(
actual, expected,
"{}: Rust-encoded bytes do not match the captured C# encoding",
case.name
);
}
}
#[test]
fn rust_decodes_the_csharp_written_bytes_back_into_the_same_typed_message() {
for case in cases() {
let bytes = hex_decode(case.csharp_hex);
let decoded = decode_cultnet_message_from_slice(&bytes, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
assert_eq!(decoded, (case.message)(), "{}: decoded value mismatch", case.name);
}
}
#[test]
fn round_trip_is_stable() {
for case in cases() {
let message = (case.message)();
let encoded = encode_cultnet_message_to_vec(&message, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: encode failed: {err}", case.name));
let decoded = decode_cultnet_message_from_slice(&encoded, CultNetWireContract::CultNetSchemaV0)
.unwrap_or_else(|err| panic!("{}: decode failed: {err}", case.name));
assert_eq!(decoded, message, "{}: round trip mismatch", case.name);
}
}