Design World Sync

    World Object Synchronization — Design Document

    Status: Planning complete, implementation not started Last updated: 2025-01-XX Scope: Synchronizing world object state (placement, removal, configuration) between host and clients in multiplayer sessions.


    Table of Contents

    1. Context & Goals
    2. Architecture Overview
    3. Design Decisions (Decision Log)
    4. Protocol Design
    5. FFI Interface (C# ↔ Rust)
    6. Event System Extensions
    7. World State Tracking (Rust)
    8. Host Logic
    9. Client Logic
    10. Hash-Check Safety Net
    11. Conflict Resolution
    12. Implementation Plan
    13. File Map — Where Things Live
    14. Open Questions / Future Work

    1. Context & Goals

    What we already have (before this feature)

    • Position + rotation sync at 20 Hz with interpolation
    • Walk/run animation sync (speed-based)
    • Nametags above remote player heads
    • Join state machine (Idle → WaitingForSave → SaveReady → LoadingScene → Loaded)
    • Save file transfer from host to joining client (chunked, hash-compared)
    • Player visual state sync (carry type, crouching, sitting)
    • Relay server over WebSocket (no direct P2P)
    • Host-authority model for save files

    What this feature adds

    Synchronization of world object state between players during gameplay. When one player places a server in a rack, connects a cable, toggles power, etc., all other players see the change in real-time.

    What this feature does NOT cover

    • Player character visual states (carry animations, crouching on remote players) — separate feature (Point A)
    • NPC/Technician synchronization
    • Shop UI synchronization (each player sees their own shop)
    • Economy synchronization (money, XP) — future feature

    2. Architecture Overview

    ┌─────────────────────────────────────────────────────────────────┐
    │                        CLIENT A (Actor)                         │
    │                                                                 │
    │  1. Player performs action (e.g., installs server in rack)      │
    │  2. Action executes LOCALLY immediately (optimistic)            │
    │  3. C# Harmony Patch fires Event (ServerInstalled)              │
    │  4. Rust mod_on_event receives it                               │
    │  5. Rust creates WorldAction { seq: N, action } message         │
    │  6. Sends via Relay to Host                                     │
    │  7. Starts 5s ACK timeout                                       │
    └──────────────────────────┬──────────────────────────────────────┘
                               │
                               ▼
                     ┌───────────────────┐
                     │   RELAY SERVER    │
                     │  (WebSocket Hub)  │
                     └────────┬──────────┘
                              │
                              ▼
    ┌─────────────────────────────────────────────────────────────────┐
    │                          HOST                                   │
    │                                                                 │
    │  1. Receives WorldAction { seq: N, action }                     │
    │  2. Validates (is the action legal right now?)                  │
    │  3a. VALID:                                                     │
    │      → Send WorldActionAck { seq: N, accepted: true } to A     │
    │      → Send WorldActionBroadcast { action } to ALL OTHERS      │
    │  3b. INVALID:                                                   │
    │      → Send WorldActionAck { seq: N, accepted: false } to A    │
    └──────────────────────────┬──────────────────────────────────────┘
                               │
                               ▼
    ┌─────────────────────────────────────────────────────────────────┐
    │                     CLIENT B (Observer)                          │
    │                                                                 │
    │  1. Receives WorldActionBroadcast { action }                    │
    │  2. Rust calls C# FFI to execute the action visually            │
    │     e.g., world_place_in_rack("SVR_01", 42)                    │
    │  3. Game object appears in rack                                 │
    └─────────────────────────────────────────────────────────────────┘
    

    Host's own actions (simplified path)

    HOST performs action locally
      → Action executes immediately
      → Harmony Patch fires Event
      → Rust creates WorldActionBroadcast { action }
      → Sends to ALL clients
      → No seq, no ACK, no timeout (host IS the authority)
    

    Safety net (periodic, every ~20 seconds)

    HOST:
      1. C# collects {object_id, mini_hash} for every world object
      2. Rust sends WorldHashCheck to all clients
    
    CLIENT:
      1. Compares each hash with local state
      2. Mismatch → sends WorldResyncRequest { object_id }
      3. Host responds with WorldResyncResponse { object_id, full_state }
      4. Client applies the corrected state
    

    3. Design Decisions (Decision Log)

    #QuestionDecisionRationale
    1Authority modelOptimistic-local + Host-broadcastClient actions feel instant (0ms local delay). Host validates and broadcasts. Rollback on reject. Round-trip latency (~300ms via relay) would feel sluggish for host-authoritative with client waiting.
    2Sync strategyEvents (primary) + Hash-check safety net (secondary)Events handle 99% of cases with instant propagation. Hash-check every ~20s catches any missed events (self-healing).
    3Hash-check variantHash-list + on-demand resync (Variante 2)Minimal bandwidth in happy path. Host sends compact hash list, client compares locally. Only fetches full state for mismatched objects.
    4Object IDsUse game's native IDsServer → String serverID, Switch → String switchID, PatchPanel → String patchPanelID, Cable → Int32 cableID. Generated via NetworkMap.GenerateDeviceName(TypeOfLink, Vector3). SFP modules get synthetic IDs (no native ID).
    5Message structureEncapsulated WorldAction sub-enumKeeps the main Message enum clean. Three channel types: WorldAction (request), WorldActionAck (response), WorldActionBroadcast (authoritative).
    6ACK systemPer-action seq number + ACK/rejectClient assigns incrementing seq per action. Host references seq in ACK. Client can correlate which action was accepted/rejected.
    7Timeout5 seconds, then rollbackIf no ACK after 5s → roll back the optimistic local action. Hash-check will correct if we rolled back incorrectly.
    8Host's own actionsDirect broadcast, no self-ACKHost is authority — no need to request permission from itself. Just execute + broadcast.
    9Conflict resolutionBroadcast always winsIf a client holds an object and receives a broadcast saying that object was placed elsewhere → object leaves the client's hand. Host authority is absolute.
    10FFI granularityFine-grained functionsOne C# function per action type. Easier to debug, each function is a one-liner on C# side. Synchronous return codes (1=OK, 0=fail).

    4. Protocol Design

    New Message variants (in protocol.rs)

    #[derive(Encode, Decode, Debug, Clone)]
    pub enum Message {
        // ── Existing messages (unchanged) ──────────────────────────
        Position { x: f32, y: f32, z: f32, rot_y: f32 },
        Hello { player_name: String, mod_version: String },
        Welcome { player_name: String, is_host: bool, spawn_x: f32, spawn_y: f32, spawn_z: f32 },
        Goodbye,
        Ping(u64),
        Pong(u64),
        RequestSave,
        SaveOffer { total_bytes: u32, chunk_count: u32, save_hash: u64 },
        SaveChunk { index: u32, data: Vec<u8> },
        SaveSkip,
        PlayerState { object_in_hand: u8, num_objects: u8, is_crouching: bool, is_sitting: bool },
    
        // ── NEW: World Object Sync ─────────────────────────────────
    
        /// Client → Host: "I performed this action" (with sequence number for ACK tracking)
        WorldAction {
            seq: u32,
            action: WorldAction,
        },
    
        /// Host → originating Client: "Your action was accepted/rejected"
        WorldActionAck {
            seq: u32,
            accepted: bool,
        },
    
        /// Host → all OTHER clients: "This action happened (authoritative)"
        WorldActionBroadcast {
            action: WorldAction,
        },
    
        /// Host → all clients: periodic hash list for desync detection
        WorldHashCheck {
            hashes: Vec<ObjectHash>,
        },
    
        /// Client → Host: "My state for this object doesn't match, send me the full state"
        WorldResyncRequest {
            object_id: String,
        },
    
        /// Host → requesting Client: "Here's the full authoritative state of that object"
        WorldResyncResponse {
            object_id: String,
            object_type: u8,
            data: Vec<u8>,  // serialized full object state
        },
    }
    

    WorldAction enum

    #[derive(Encode, Decode, Debug, Clone)]
    pub enum WorldAction {
        /// Player picked up an object from the world (object disappears from world)
        ObjectPickedUp {
            object_id: String,
            object_type: u8,    // maps to ObjectInHand enum
        },
    
        /// Player dropped/placed an object in the world
        ObjectDropped {
            object_id: String,
            object_type: u8,
            pos_x: f32,
            pos_y: f32,
            pos_z: f32,
            rot_x: f32,
            rot_y: f32,
            rot_z: f32,
            rot_w: f32,
        },
    
        /// Object installed into a rack slot
        InstalledInRack {
            object_id: String,
            object_type: u8,
            rack_position_uid: i32,
        },
    
        /// Object removed from a rack slot
        RemovedFromRack {
            object_id: String,
            object_type: u8,
        },
    
        /// Server/Switch power toggled
        PowerToggled {
            object_id: String,
            is_on: bool,
        },
    
        /// Generic property change (IP, customer, label, etc.)
        PropertyChanged {
            object_id: String,
            key: String,       // e.g. "ip", "customer_id", "label", "app_id"
            value: String,     // string-encoded value
        },
    
        /// Cable connected between two endpoints
        CableConnected {
            cable_id: i32,
            start_type: u8,       // TypeOfLink enum value
            start_pos_x: f32,
            start_pos_y: f32,
            start_pos_z: f32,
            start_device_id: String,
            end_type: u8,
            end_pos_x: f32,
            end_pos_y: f32,
            end_pos_z: f32,
            end_device_id: String,
        },
    
        /// Cable disconnected/removed
        CableDisconnected {
            cable_id: i32,
        },
    
        /// New object spawned into the world (e.g., shop delivery, or SFP from box)
        ObjectSpawned {
            object_id: String,
            object_type: u8,
            prefab_id: i32,
            pos_x: f32,
            pos_y: f32,
            pos_z: f32,
            rot_x: f32,
            rot_y: f32,
            rot_z: f32,
            rot_w: f32,
        },
    
        /// Object permanently destroyed (e.g., thrown in dumpster)
        ObjectDestroyed {
            object_id: String,
            object_type: u8,
        },
    }
    

    ObjectHash (for hash-check messages)

    #[derive(Encode, Decode, Debug, Clone)]
    pub struct ObjectHash {
        pub object_id: String,
        pub object_type: u8,
        pub hash: u32,
    }
    

    Object type constants (matching ObjectInHand enum from the game)

    pub mod object_types {
        pub const NONE: u8 = 0;
        pub const SERVER_1U: u8 = 1;
        pub const SERVER_7U: u8 = 2;
        pub const SERVER_3U: u8 = 3;
        pub const SWITCH: u8 = 4;
        pub const RACK: u8 = 5;
        pub const CABLE_SPINNER: u8 = 6;
        pub const PATCH_PANEL: u8 = 7;
        pub const SFP_MODULE: u8 = 8;
        pub const SFP_BOX: u8 = 9;
    }
    

    Reliability

    All WorldAction, WorldActionAck, WorldActionBroadcast, and hash-check messages are reliable (they must arrive). They go through the relay as RelayPacket::GameData, which uses TCP (WebSocket), so delivery is guaranteed.


    5. FFI Interface (C# ↔ Rust)

    All new functions follow the existing pattern in GameAPI struct (dc_api/src/lib.rs). C# implements these as thin wrappers calling into game objects via IL2CPP.

    Group 1: Read Functions (for hash-check)

    ┌─────────────────────────────────────────────────────────────────────────────┐
    │ Function                              │ Signature                          │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_get_object_count                │ () → u32                          │
    │   Returns total number of syncable    │                                    │
    │   world objects (servers + switches   │                                    │
    │   + patch panels + cables + SFPs)     │                                    │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_get_object_hashes              │ (buf: *mut ObjectHashFFI,          │
    │   Fills buffer with {id, type, hash}  │  max_count: u32) → u32           │
    │   for every world object.             │  Returns count written.            │
    │   Hash = hash of (pos, rot, rackUID,  │                                    │
    │   isOn, isBroken, key properties)     │                                    │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_get_object_state               │ (id: *const u8, id_len: u32,      │
    │   Returns the full serialized state   │  buf: *mut u8, buf_max: u32)      │
    │   of a single object (for resync).    │  → u32 (bytes written)            │
    └───────────────────────────────────────┴────────────────────────────────────┘
    

    ObjectHashFFI layout (C-compatible struct)

    #[repr(C)]
    pub struct ObjectHashFFI {
        pub object_id: [u8; 64],    // null-terminated UTF-8 string
        pub object_id_len: u32,
        pub object_type: u8,
        pub hash: u32,
    }
    

    Group 2: Write Functions (for applying remote actions)

    All return i32: 1 = success, 0 = failure.

    ┌─────────────────────────────────────────────────────────────────────────────┐
    │ FunctionSignature                          │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_spawn_object                   │ (object_type: u8, prefab_id: i32, │
    │   Spawns a new object at position.    │  x: f32, y: f32, z: f32,         │
    │   Returns object ID in out buffer.    │  rot_x: f32, rot_y: f32,         │
    │                                       │  rot_z: f32, rot_w: f32,         │
    │                                       │  out_id: *mut u8, out_max: u32)  │
    │                                       │  → i32                            │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_destroy_object                 │ (id: *const u8, id_len: u32)      │
    │   Permanently removes object.         │  → i32                            │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_place_in_rack                  │ (id: *const u8, id_len: u32,      │
    │   Installs object into rack slot.     │  rack_uid: i32) → i32            │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_remove_from_rack               │ (id: *const u8, id_len: u32)      │
    │   Removes object from its rack slot.  │  → i32                            │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_set_power                      │ (id: *const u8, id_len: u32,      │
    │   Toggles power on server/switch.     │  is_on: u8) → i32                │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_set_property                   │ (id: *const u8, id_len: u32,      │
    │   Sets a named property.              │  key: *const u8, key_len: u32,   │
    │   Keys: "ip", "customer_id",         │  val: *const u8, val_len: u32)   │
    │   "label", "app_id"                  │  → i32                            │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_connect_cable                  │ (cable_id: i32,                   │
    │   Connects a cable between endpoints. │  start_type: u8,                  │
    │                                       │  sx: f32, sy: f32, sz: f32,      │
    │                                       │  start_device: *const u8,         │
    │                                       │  start_device_len: u32,           │
    │                                       │  end_type: u8,                    │
    │                                       │  ex: f32, ey: f32, ez: f32,      │
    │                                       │  end_device: *const u8,           │
    │                                       │  end_device_len: u32) → i32      │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_disconnect_cable               │ (cable_id: i32) → i32             │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_pickup_object                  │ (id: *const u8, id_len: u32)      │
    │   Removes object from world           │  → i32                            │
    │   (as if a remote player picked it    │                                    │
    │   up — object disappears visually).   │                                    │
    ├───────────────────────────────────────┼────────────────────────────────────┤
    │ world_drop_object                    │ (id: *const u8, id_len: u32,      │
    │   Places object back into world at    │  x: f32, y: f32, z: f32,         │
    │   position (as if remote player       │  rot_x: f32, rot_y: f32,         │
    │   dropped it).                        │  rot_z: f32, rot_w: f32) → i32   │
    └───────────────────────────────────────┴────────────────────────────────────┘
    

    Where to add these

    • Rust side: Add function pointers to GameAPI struct in crates/dc_api/src/lib.rs (lines ~206-360)
    • Rust wrappers: Add safe wrapper methods to impl Api in crates/dc_api/src/lib.rs (lines ~372-1229)
    • C# side: Implement in the ModLoader's FFI bridge (same pattern as existing functions like get_player_position, spawn_character, etc.)

    6. Event System Extensions

    Existing events that need richer payloads

    The event system lives in crates/dc_api/src/events/. Events are fired from C# Harmony patches and decoded in mod.rs.

    Event (in event_id.rs)Current PayloadNeeded Payload
    ServerInstalled (ID 206)noneserver_id: String, rack_position_uid: i32, prefab_id: i32
    ServerPowered (ID 200)powered_on: bool+ server_id: String
    CableConnected (ID 207)nonecable_id: i32, start/end positions + types + device IDs
    CableDisconnected (ID 208)nonecable_id: i32
    RackUnmounted (ID 211)noneobject_id: String, object_type: u8
    ServerCustomerChanged (ID 209)customer_id: i32+ server_id: String
    ServerAppChanged (ID 210)app_id: i32+ server_id: String
    WallPurchased (ID 800)nonewall_index: i32

    New events to add

    EventID (proposed)Payload
    ObjectPickedUp212object_id: String, object_type: u8
    ObjectDropped213object_id: String, object_type: u8, pos: Vec3, rot: Quaternion
    ObjectSpawned214object_id: String, object_type: u8, prefab_id: i32, pos: Vec3, rot: Quaternion
    ObjectDestroyed215object_id: String, object_type: u8
    SwitchInstalled216switch_id: String, rack_position_uid: i32
    PatchPanelInstalled217patch_panel_id: String, rack_position_uid: i32
    SFPInserted218sfp_id: String, port_position: Vec3

    Where to add these

    • Event IDs: crates/dc_api/src/events/event_id.rs
    • Payload structs: crates/dc_api/src/events/payload.rs
    • Event enum variants: crates/dc_api/src/events/event.rs
    • Decode logic: crates/dc_api/src/events/mod.rs (in the decode function)
    • C# Harmony Patches: In the ModLoader C# project (extend existing patches, add new ones)

    How events flow into WorldAction messages

    In dc_multiplayer, the mod_on_event handler (or a dedicated function called from it) converts game events into WorldAction messages:

    // In dc_multiplayer, pseudocode:
    fn on_game_event(event: Event) {
        let action = match event {
            Event::ServerInstalled { server_id, rack_position_uid, .. } => {
                WorldAction::InstalledInRack {
                    object_id: server_id,
                    object_type: object_types::SERVER_1U, // or whichever type
                    rack_position_uid,
                }
            }
            Event::ObjectPickedUp { object_id, object_type } => {
                WorldAction::ObjectPickedUp { object_id, object_type }
            }
            // ... etc
            _ => return, // not a world-sync event
        };
    
        let is_host = with_state(|s| s.is_host).unwrap_or(false);
        if is_host {
            // Host: broadcast directly
            send_broadcast(WorldActionBroadcast { action });
        } else {
            // Client: send request with seq
            let seq = next_seq();
            register_pending_action(seq, action.clone(), rollback_info);
            send_to_host(WorldAction { seq, action });
        }
    }
    

    7. World State Tracking (Rust)

    New module: crates/dc_multiplayer/src/world.rs

    This module tracks:

    1. Pending actions — actions the local client performed optimistically, awaiting ACK
    2. Sequence counter — incrementing seq for outgoing actions
    3. Timeout tracking — 5-second timer per pending action
    pub struct PendingAction {
        pub seq: u32,
        pub action: WorldAction,
        pub sent_at: f32,          // game time when sent
        pub rollback_info: RollbackInfo,  // data needed to undo the action
    }
    
    pub enum RollbackInfo {
        /// Object was picked up — rollback = drop it back
        UndoPickup {
            object_id: String,
            object_type: u8,
            original_pos: (f32, f32, f32),
            original_rot: (f32, f32, f32, f32),
        },
        /// Object was dropped — rollback = pick it back up (remove from world)
        UndoDrop {
            object_id: String,
        },
        /// Object was installed in rack — rollback = remove from rack, place at previous pos
        UndoInstall {
            object_id: String,
            object_type: u8,
            previous_pos: (f32, f32, f32),
            previous_rot: (f32, f32, f32, f32),
        },
        /// Object was removed from rack — rollback = put back in rack
        UndoRemoveFromRack {
            object_id: String,
            object_type: u8,
            rack_position_uid: i32,
        },
        /// Power was toggled — rollback = toggle back
        UndoPowerToggle {
            object_id: String,
            was_on: bool,
        },
        /// Property was changed — rollback = set old value
        UndoPropertyChange {
            object_id: String,
            key: String,
            old_value: String,
        },
        /// Cable was connected — rollback = disconnect
        UndoCableConnect {
            cable_id: i32,
        },
        /// Cable was disconnected — rollback = reconnect (store full endpoint info)
        UndoCableDisconnect {
            cable_id: i32,
            // store full CableConnected data for re-connection
            start_type: u8,
            start_pos: (f32, f32, f32),
            start_device_id: String,
            end_type: u8,
            end_pos: (f32, f32, f32),
            end_device_id: String,
        },
        /// Object was spawned — rollback = destroy
        UndoSpawn {
            object_id: String,
        },
        /// Object was destroyed — rollback = respawn
        UndoDestroy {
            object_id: String,
            object_type: u8,
            prefab_id: i32,
            pos: (f32, f32, f32),
            rot: (f32, f32, f32, f32),
        },
        /// No rollback needed/possible
        None,
    }
    
    pub struct WorldSyncState {
        /// Next sequence number for outgoing actions
        pub next_seq: u32,
    
        /// Actions awaiting ACK from host (client only)
        pub pending_actions: Vec<PendingAction>,
    
        /// Timer for hash-check broadcasts (host only)
        pub hash_check_timer: f32,
    
        /// Last known object hashes (client, for comparison)
        pub last_known_hashes: HashMap<String, u32>,
    }
    

    Where to add this state

    Add WorldSyncState as a field in MultiplayerState (in crates/dc_multiplayer/src/state.rs, line ~60):

    pub struct MultiplayerState {
        // ... existing fields ...
    
        /// World object synchronization state
        pub world_sync: WorldSyncState,
    }
    

    Constants (in state.rs)

    pub const WORLD_ACTION_TIMEOUT_SECS: f32 = 5.0;
    pub const HASH_CHECK_INTERVAL_SECS: f32 = 20.0;
    

    8. Host Logic

    Location: crates/dc_multiplayer/src/handlers.rs

    Add a new handler in handle_message for Message::WorldAction:

    Message::WorldAction { seq, action } => {
        if !is_host { return; }  // only host processes these
    
        let accepted = validate_world_action(&action);
    
        // Send ACK to the requesting client
        let ack = Message::WorldActionAck { seq, accepted };
        send_to(sender, ack);
    
        if accepted {
            // Execute the action on host side (if not already done — see note below)
            // Note: If the host receives an action from a client, the host does NOT
            // need to execute it locally — the action only affects the CLIENT's world.
            // The host just validates and broadcasts.
            //
            // BUT: If the action creates server-side state (like generating an ID),
            // the host needs to handle that.
    
            // Broadcast to all OTHER clients (not the sender, they already did it optimistically)
            broadcast_to_others(sender, Message::WorldActionBroadcast { action });
        }
    }
    

    Validation function

    fn validate_world_action(action: &WorldAction) -> bool {
        // For Phase 2, start with simple validation:
        match action {
            WorldAction::InstalledInRack { object_id, rack_position_uid, .. } => {
                // Check: does the object exist? Is the rack slot empty?
                // Call into GameAPI to verify
                true // placeholder
            }
            WorldAction::RemovedFromRack { object_id, .. } => {
                // Check: is the object actually in a rack?
                true
            }
            // ... etc
            _ => true  // default: accept
        }
    }
    

    Host's own actions

    When the host performs an action and receives the game event in mod_on_event:

    // In the event→WorldAction conversion:
    if is_host {
        // Don't send WorldAction (that's for clients requesting).
        // Just broadcast directly:
        let broadcast = Message::WorldActionBroadcast { action };
        send_to_all_clients(broadcast);
        // No seq, no pending action, no timeout.
    }
    

    9. Client Logic

    Receiving WorldActionAck

    In handlers.rs, add handler:

    Message::WorldActionAck { seq, accepted } => {
        if is_host { return; }  // host doesn't receive ACKs
    
        with_state(|s| {
            if let Some(idx) = s.world_sync.pending_actions.iter().position(|p| p.seq == seq) {
                let pending = s.world_sync.pending_actions.remove(idx);
                if !accepted {
                    // ROLLBACK: undo the optimistic action
                    execute_rollback(api, &pending.rollback_info);
                    api.show_notification("Action was rejected by host.");
                }
                // If accepted: nothing to do, the optimistic action was correct.
            }
        });
    }
    

    Receiving WorldActionBroadcast

    In handlers.rs, add handler:

    Message::WorldActionBroadcast { action } => {
        // This is an authoritative action from the host.
        // Execute it locally via FFI.
        execute_world_action(api, &action);
    }
    

    execute_world_action (calls C# FFI)

    fn execute_world_action(api: &Api, action: &WorldAction) {
        match action {
            WorldAction::InstalledInRack { object_id, object_type, rack_position_uid } => {
                api.world_place_in_rack(object_id, *rack_position_uid);
            }
            WorldAction::RemovedFromRack { object_id, .. } => {
                api.world_remove_from_rack(object_id);
            }
            WorldAction::PowerToggled { object_id, is_on } => {
                api.world_set_power(object_id, *is_on);
            }
            WorldAction::ObjectPickedUp { object_id, .. } => {
                api.world_pickup_object(object_id);
            }
            WorldAction::ObjectDropped { object_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, .. } => {
                api.world_drop_object(object_id, *pos_x, *pos_y, *pos_z, *rot_x, *rot_y, *rot_z, *rot_w);
            }
            // ... etc for all action types
        }
    }
    

    Timeout check (in tick loop)

    In tick.rs or world.rs, called every frame:

    fn check_pending_action_timeouts(api: &Api, game_time: f32) {
        with_state(|s| {
            let timed_out: Vec<PendingAction> = s.world_sync.pending_actions
                .drain_filter(|p| game_time - p.sent_at > WORLD_ACTION_TIMEOUT_SECS)
                .collect();
    
            for pending in timed_out {
                dc_api::crash_log(&format!(
                    "[WORLD] Action seq={} timed out after 5s, rolling back",
                    pending.seq
                ));
                execute_rollback(api, &pending.rollback_info);
            }
        });
    }
    

    10. Hash-Check Safety Net

    Host-side (periodic broadcast)

    In tick.rs, add to the update loop (host only):

    // Only host sends hash checks
    if is_host {
        with_state(|s| {
            s.world_sync.hash_check_timer += dt;
            if s.world_sync.hash_check_timer >= HASH_CHECK_INTERVAL_SECS {
                s.world_sync.hash_check_timer = 0.0;
                true
            } else {
                false
            }
        });
    
        if should_hash_check {
            // Read object hashes from C# via FFI
            let hashes = api.world_get_object_hashes();  // returns Vec<ObjectHash>
    
            let msg = Message::WorldHashCheck { hashes };
            broadcast_to_all(msg);
        }
    }
    

    Client-side (comparison + resync request)

    In handlers.rs:

    Message::WorldHashCheck { hashes } => {
        if is_host { return; }
    
        // Get our local hashes
        let local_hashes = api.world_get_object_hashes();
        let local_map: HashMap<String, u32> = local_hashes.iter()
            .map(|h| (h.object_id.clone(), h.hash))
            .collect();
        let remote_map: HashMap<String, u32> = hashes.iter()
            .map(|h| (h.object_id.clone(), h.hash))
            .collect();
    
        // Check for mismatches
        for (id, remote_hash) in &remote_map {
            match local_map.get(id) {
                Some(local_hash) if local_hash == remote_hash => {
                    // Match — all good
                }
                _ => {
                    // Mismatch or missing locally — request resync
                    dc_api::crash_log(&format!("[WORLD] Hash mismatch for {}, requesting resync", id));
                    send_to_host(Message::WorldResyncRequest { object_id: id.clone() });
                }
            }
        }
    
        // Check for objects we have but host doesn't (should be deleted)
        for (id, _) in &local_map {
            if !remote_map.contains_key(id) {
                dc_api::crash_log(&format!("[WORLD] Object {} exists locally but not on host, removing", id));
                api.world_destroy_object(id);
            }
        }
    }
    

    Host responds to resync request

    Message::WorldResyncRequest { object_id } => {
        if !is_host { return; }
    
        // Get full state of the requested object
        let (object_type, data) = api.world_get_object_state(&object_id);
    
        let response = Message::WorldResyncResponse {
            object_id,
            object_type,
            data,
        };
        send_to(sender, response);
    }
    

    Client applies resync response

    Message::WorldResyncResponse { object_id, object_type, data } => {
        if is_host { return; }
    
        // Destroy local version if it exists
        api.world_destroy_object(&object_id);
    
        // Deserialize and recreate from the authoritative data
        apply_full_object_state(api, &object_id, object_type, &data);
    
        dc_api::crash_log(&format!("[WORLD] Resynced object {}", object_id));
    }
    

    11. Conflict Resolution

    Rule: Broadcast always wins (Host authority is absolute)

    When a client receives a WorldActionBroadcast for an object it currently has in a "pending" state (waiting for ACK on its own action for that object):

    fn execute_world_action(api: &Api, action: &WorldAction) {
        let object_id = action.object_id();  // helper to extract ID from any variant
    
        // If we have a pending action for this same object, the broadcast overrides it.
        with_state(|s| {
            s.world_sync.pending_actions.retain(|p| {
                if p.action.object_id() == object_id {
                    dc_api::crash_log(&format!(
                        "[WORLD] Broadcast overrides pending action seq={} for {}",
                        p.seq, object_id
                    ));
                    // Don't rollback — the broadcast itself IS the correction
                    false  // remove from pending
                } else {
                    true   // keep
                }
            });
        });
    
        // Now execute the broadcast action
        match action {
            // ... (same as section 9)
        }
    }
    

    Edge case: Object in local player's hand

    If the local player is holding an object and a broadcast says that object was placed/installed somewhere:

    WorldAction::InstalledInRack { object_id, .. } |
    WorldAction::ObjectDropped { object_id, .. } => {
        // Check if we're holding this object
        let carry_state = api.get_player_carry_state();
        // If our local player has this object → force-drop it
        // (The GameAPI would need a force_drop_held_object function,
        //  or we handle this in C# when world_place_in_rack is called
        //  for an object the local player holds)
    }
    

    12. Implementation Plan

    Phase 1 — Foundation (no networking, testable in isolation)

    Goal: All data structures and FFI interfaces defined. Compiles and can be tested with mock data.

    TaskFile(s)Description
    1.1crates/dc_multiplayer/src/protocol.rsAdd WorldAction enum, ObjectHash, new Message variants
    1.2crates/dc_multiplayer/src/world.rs (NEW)PendingAction, RollbackInfo, WorldSyncState structs
    1.3crates/dc_multiplayer/src/state.rsAdd world_sync: WorldSyncState to MultiplayerState, add constants
    1.4crates/dc_multiplayer/src/lib.rsAdd mod world;
    1.5crates/dc_api/src/lib.rsAdd new function pointers to GameAPI, add safe wrappers to impl Api
    1.6C# ModLoaderImplement the FFI functions (stubs first, then real implementations)

    Acceptance criteria: cargo build succeeds. Unit tests for serialization/deserialization of new message types pass.

    Phase 2 — First End-to-End Roundtrip

    Goal: One action type (ServerInstalled) works end-to-end: Host installs server → Client sees it. Client installs server → Host ACKs → other clients see it.

    TaskFile(s)Description
    2.1crates/dc_api/src/events/Extend ServerInstalled event with server_id, rack_position_uid
    2.2C# Harmony PatchesUpdate ServerInstalled patch to include server ID and rack UID
    2.3crates/dc_multiplayer/src/handlers.rsHandle WorldAction, WorldActionAck, WorldActionBroadcast messages
    2.4crates/dc_multiplayer/src/tick.rs or world.rsEvent → WorldAction conversion, sending logic
    2.5C# ModLoaderImplement world_place_in_rack for real
    2.6TESTTwo players: both install servers, verify sync

    Acceptance criteria: Host installs server → client sees it appear in rack within ~300ms. Client installs server → Host ACKs → client keeps it → other clients see it.

    Phase 3 — All Action Types

    Goal: All world actions synchronize correctly.

    TaskFile(s)Description
    3.1Events + Harmony PatchesAdd all remaining events (ObjectPickedUp, ObjectDropped, CableConnected, etc.)
    3.2handlers.rsHandle all WorldAction variants
    3.3C# ModLoaderImplement all remaining FFI write functions
    3.4tick.rs / world.rsTimeout tracking + rollback execution
    3.5TESTFull gameplay session with all action types

    Acceptance criteria: All actions in the table from the design sync correctly. Rollbacks work on timeout.

    Phase 4 — Hash-Check Safety Net

    Goal: Periodic desync detection and automatic correction.

    TaskFile(s)Description
    4.1C# ModLoaderImplement world_get_object_hashes and world_get_object_state
    4.2crates/dc_api/src/lib.rsWrappers for hash/state read functions
    4.3tick.rsHost: periodic hash broadcast. Client: comparison logic.
    4.4handlers.rsWorldHashCheck, WorldResyncRequest, WorldResyncResponse handlers
    4.5TESTIntentionally desync (drop a network packet) → verify auto-correction within ~20s

    Acceptance criteria: Artificially introduced desyncs are automatically corrected within one hash-check interval.


    13. File Map — Where Things Live

    Rust crate: dc_multiplayer (crates/dc_multiplayer/src/)

    FilePurposeWhat to change
    protocol.rsNetwork message definitionsAdd WorldAction, ObjectHash, new Message variants
    world.rsNEW — World sync state, pending actions, rollback logicCreate from scratch
    state.rsGlobal multiplayer stateAdd WorldSyncState field, new constants
    handlers.rsMessage processingAdd handlers for all new message types
    tick.rsPer-frame update loopAdd world action sending, timeout checks, hash-check timing
    ffi.rsFFI exports for C#May need new exports if C# needs to push world events to Rust
    lib.rsModule declarationsAdd mod world;
    save.rsSave transferUnchanged
    net.rsWebSocket relay connectionUnchanged
    player.rsRemote player trackingUnchanged

    Rust crate: dc_api (crates/dc_api/src/)

    FilePurposeWhat to change
    lib.rsGameAPI struct + Api wrapperAdd ~12 new function pointers + safe wrappers
    events/event_id.rsEvent ID constantsAdd new event IDs (212-218)
    events/event.rsEvent enumAdd new variants with extended payloads
    events/payload.rsFFI payload structsAdd new #[repr(C)] structs for extended event data
    events/mod.rsEvent decodingAdd decode cases for new/extended events

    C# ModLoader (not in Rust workspace)

    ComponentWhat to change
    GameAPI FFI bridgeAdd ~12 new function implementations
    Harmony PatchesExtend existing patches (more payload data), add new patches (PickUp, Drop, Spawn, Destroy)
    EventIds.csAdd new event ID constants matching Rust

    Relay server (dc_relay_proto)

    FileWhat to change
    src/lib.rsNothing — relay is transport-agnostic, just forwards GameData payloads

    14. Open Questions / Future Work

    Open questions (to resolve during implementation)

    1. SFP Module IDs: SFPs have no native ID. How to generate synthetic IDs? Proposal: "SFP_{prefabID}_{x:.0}_{y:.0}_{z:.0}" based on initial position.

    2. Cable waypoints: CableSaveData has List<waypoints> and List<midPointPositions>. Do we need to sync these in CableConnected, or does the game auto-generate them from start/end positions?

    3. Shop delivery sync: When a player buys items in the shop, objects spawn in the delivery area. The ShopCheckout event needs to be extended to include what was purchased, or we rely on the hash-check to pick up new objects.

    4. Object state serialization format: For world_get_object_state (resync), what binary format? Options: (a) reuse the game's own SaveData serialization per-object, (b) custom #[repr(C)] structs, (c) bincode.

    5. Trolley position: The trolley is a shared physics object. Should it be synced via WorldAction (explicit push events) or via periodic position sync (like player positions)?

    6. Rack doors: RackDoor : Interact — trivial to sync (open/close), but is it worth the network traffic? Could be Phase 5.

    Future work (beyond this design)

    • Economy sync: Money, XP, reputation changes need to be host-authoritative
    • Customer acceptance sync: When host accepts a customer, clients need to see it
    • Technician/NPC sync: Technicians moving around, repairing, replacing — complex animation sync
    • Undo/Redo: If rollbacks become common, a proper undo stack might be needed
    • Bandwidth optimization: Delta compression for hash-check lists, bitpacking for common actions
    • Latency compensation: Predictive placement for observers (show action slightly before it's confirmed)

    Appendix A: Game Object Reference (from IL2CPP inspection)

    SaveData (root — full_output.txt L1605-1627)

    SaveData._current
    SaveData.playerData
    SaveData.networkData              → NetworkSaveData
    SaveData.rackMountObjectData      → List<RackMountObjectData>
    SaveData.isWallOpened             → bool[]
    SaveData.interactObjectData       → List<InteractObjectData>
    SaveData.lastUsedRackPositionGlobalUID → int  (incrementing counter)
    SaveData.wallPrice                → float
    SaveData.trolleyPosition          → Vector3
    SaveData.trolleyRotation          → Quaternion
    

    NetworkSaveData (full_output.txt L1191-1200)

    NetworkSaveData.servers           → List<ServerSaveData>
    NetworkSaveData.switches          → List<SwitchSaveData>
    NetworkSaveData.patchPanels       → List<PatchPanelSaveData>
    NetworkSaveData.cables            → List<CableSaveData>
    NetworkSaveData.customerBases     → List<CustomerBaseSaveData>
    NetworkSaveData.sfpModules        → List<SFPSaveData>
    NetworkSaveData.lacpGroups        → List<LACPGroupSaveData>
    

    ServerSaveData (full_output.txt L1718-1733)

    serverID          : String         UNIQUE ID (generated by GenerateDeviceName)
    customerID        : int
    ip                : String
    serverType        : int
    position          : Vector3
    rotation          : Quaternion
    rackPositionUID   : int            which rack slot (0 or -1 if not in rack?)
    prefabID          : int
    isOn              : bool
    isBroken          : bool
    timeToBrake       : int
    eolTime           : int
    isWarningCleared  : bool
    

    SwitchSaveData (full_output.txt L1905-1917)

    switchID          : String         UNIQUE ID
    switchType        : int
    position          : Vector3
    rotation          : Quaternion
    rackPositionUID   : int
    isOn              : bool
    label             : String
    isBroken          : bool
    timeToBrake       : int
    eolTime           : int
    isWarningCleared  : bool
    

    PatchPanelSaveData (full_output.txt L1296-1303)

    patchPanelID      : String         UNIQUE ID
    position          : Vector3
    rotation          : Quaternion
    rackPositionUID   : int
    patchPanelType    : int
    

    CableSaveData (full_output.txt L547-555)

    cableID           : int            UNIQUE ID (incrementing)
    startPoint        : CableEndpointSaveData
    endPoint          : CableEndpointSaveData
    waypoints         : List<Vector3>
    midPointPositions : List<Vector3>
    maxSpeed          : float
    cableColor        : Color
    

    CableEndpointSaveData (full_output.txt L538-545)

    type              : TypeOfLink    ← enum (Server, Switch, PatchPanel, CustomerBase, ...)
    position          : Vector3
    customerID        : int
    switchID          : String
    serverID          : String
    

    SFPSaveData (full_output.txt L1830-1836)

    prefabID          : int
    position          : Vector3
    rotation          : Quaternion
    isInserted        : bool
    portPosition      : Vector3
    

    ObjectInHand enum (from item_type_name in event.rs)

    0 = None
    1 = Server1U
    2 = Server7U
    3 = Server3U
    4 = Switch
    5 = Rack
    6 = CableSpinner
    7 = PatchPanel
    8 = SFPModule
    9 = SFPBox
    

    Key game classes for hooking

    NetworkMap           (L1146-1195) — RegisterServer, RegisterSwitch, GenerateDeviceName, etc.
    Server               (L1664-1717) — ServerInsertedInRack, PowerButton, SetIP, UpdateCustomer, etc.
    NetworkSwitch        (L1200-1245) — similar to Server
    PatchPanel           (L1281-1300) — similar
    RackPosition         (L207)       — InsertItemInRack (coroutine)
    RackMount            (L204)       — InstallRack (coroutine)
    Rack                 (L196)       — UnmountRack (coroutine)
    CableLink            (L36)        — cable endpoint interaction
    SFPModule            (L246)       — SlideIntoPort (coroutine)
    PlayerManager        (L1445-1475) — objectInHand, numberOfObjectsInHand
    SaveSystem           (L1628-1652) — SaveGame, LoadGame
    

    Appendix B: Latency Analysis

    Network path (via relay)

    Client  Relay:  ~75ms  (half of client's ping to relay)
    Relay   Host:   ~75ms  (half of host's ping to relay)
    Host    Relay:  ~75ms
    Relay   Client: ~75ms
    ─────────────────────────
    Total round-trip: ~300ms (with 150ms ping to relay for both parties)
    

    What the player experiences

    ScenarioLatency for actorLatency for observers
    Client performs action0ms (optimistic local)~300ms (broadcast via host)
    Host performs action0ms (local)~150ms (direct broadcast)
    ACK arrives at client~300msN/A
    Hash-check correction~20s + ~300ms~20s + ~300ms

    Why optimistic-local is essential

    Without it, the acting player would wait 300ms before seeing their own action — unacceptable for placing objects, connecting cables, etc. With optimistic-local, the acting player sees 0ms delay, and the 300ms only affects remote observers (which is fine for a datacenter simulator).