Search Unity

  1. Are you interested in providing feedback directly to Unity teams? Sign up to become a member of Unity Pulse, our new product feedback and research community.
    Dismiss Notice

Unity Multiplayer [Unsatisfied Resolved] How to prevent [SyncVar] data corruptions happening randomly?

Discussion in 'Multiplayer' started by asperatology, Nov 22, 2015.

  1. asperatology

    asperatology

    Joined:
    Mar 10, 2015
    Posts:
    976


    This game is tested on a "localhost" IP on the same computer. Left is the client, right is the server. Sorry for not being able to make the screenshot more apparent.

    This is what happens:



    Order of operations as follows:
    1. Player selects 1 unit.
    2. Player orders "Split" command.
    3. Unit game object calls on [Command], which calls on [ClientRpc].
    4. The [ClientRpc] contains an action where the Unit game object is instantiated on itself, creating a copy.
    5. The Unit game object and the Copy is then put into a struct.
    6. This struct is then added into a splitting manager's list of structs.
    7. We leave the [ClientRpc]. The server and the client now have non-empty split managers.
    8. If the splitting manager's list of structs is not empty, update each struct in the list.
    9. The struct containing the unit and the copy finally finishes. Struct is deleted.
    10. For unknown reasons, the values in either the original unit or the copy are corrupted.
    This is all I know of so far.

    This is the code for the Split Manager:

    Code (CSharp):
    1.     public void Update() {
    2.         if (!this.hasAuthority) {
    3.             return;
    4.         }
    5.  
    6.         //When the player starts the action to split a game unit into two, it takes in all the selected game units
    7.         //one by one, and splits them individually.
    8.         if (Input.GetKeyDown(KeyCode.S)) {
    9.             if (this.selectionManager != null) {
    10.                 AddingNewSplitGroup(); //Just adding new groups to the list.
    11.             }
    12.         }
    13.         UpdateSplitGroup(); //Just updating any groups inside the list.
    14.     }
    15.  
    16.     public void UpdateSplitGroup() {
    17.         if (this.splitGroupList != null && this.splitGroupList.Count > 0) {
    18.             for (int i = 0; i < this.splitGroupList.Count; i++) {
    19.                 SplitGroup group = this.splitGroupList[i];
    20.                 if (group.elapsedTime > 1f) {
    21.                     group.Stop();
    22.                     if (group.splitUnit != null && !this.selectionManager.allObjects.Contains(group.splitUnit.gameObject)) {
    23.                         this.selectionManager.allObjects.Add(group.splitUnit.gameObject);
    24.                     }
    25.                     if (!this.selectionManager.allObjects.Contains(group.ownerUnit.gameObject)) {
    26.                         this.selectionManager.allObjects.Add(group.ownerUnit.gameObject);
    27.                     }
    28.                     this.removeList.Add(group);  //Nothing is involved in modifying unit attributes.
    29.                 }
    30.                 else {
    31.                     //Some weird C# language design...
    32.                     group.Update();
    33.                     group.elapsedTime += Time.deltaTime / group.splitFactor;
    34.                     this.splitGroupList[i] = group;
    35.                 }
    36.             }
    37.         }
    38.  
    39.         if (this.removeList != null && this.removeList.Count > 0) {
    40.             foreach (SplitGroup group in this.removeList) {
    41.                 this.splitGroupList.Remove(group);
    42.             }
    43.             this.removeList.Clear();
    44.         }
    45.     }
    46.  
    47.     private void AddingNewSplitGroup() {
    48.         foreach (GameObject obj in this.selectionManager.selectedObjects) {
    49.             if (obj == null) {
    50.                 this.selectionManager.removeList.Add(obj);
    51.                 continue;
    52.             }
    53.             GameUnit objUnit = obj.GetComponent<GameUnit>();
    54.             if (objUnit.level == 1) {
    55.                 CmdSplit(obj, objUnit.hasAuthority);
    56.             }
    57.         }
    58.         return;
    59.     }
    60.  

    This is the network code for Split Manager. I swear I'm pretty sure data corruptions happen in this code snippet, but I can't tell for sure.

    Code (CSharp):
    1. [Command]
    2. public void CmdSplit(GameObject obj, bool hasAuthority) {
    3.     GameUnit unit = obj.GetComponent<GameUnit>();
    4.     if (unit.attributes == null) {
    5.         if (this.unitAttributes != null) {
    6.             unit.attributes = this.unitAttributes;
    7.         }
    8.         else {
    9.             Debug.LogError("Definitely something is wrong here with unit attributes.");
    10.         }
    11.     }
    12.  
    13.     if (unit.isSplitting) {
    14.         return;
    15.     }
    16.  
    17.     //This is profoundly one of the hardest puzzles I had tackled. Non-player object spawning non-player object.
    18.     //Instead of the usual spawning design used in the Spawner script, the spawning codes here are swapped around.
    19.     //In Spawner, you would called on NetworkServer.SpawnWithClientAuthority() in the [ClientRpc]. Here, it's in [Command].
    20.     //I am guessing it has to do with how player objects and non-player objects interact with UNET.
    21.     GameObject split = MonoBehaviour.Instantiate(this.gameUnitPrefab) as GameObject;
    22.     split.transform.position = obj.transform.position;
    23.  
    24.     GameUnit splitUnit = split.GetComponent<GameUnit>();
    25.     if (splitUnit != null) {
    26.         splitUnit.isSplitting = unit.isSplitting = true;
    27.     }
    28.  
    29.     NetworkIdentity managerIdentity = this.GetComponent<NetworkIdentity>();
    30.     NetworkServer.SpawnWithClientAuthority(split, managerIdentity.clientAuthorityOwner);
    31.     float angle = UnityEngine.Random.Range(-180f, 180f);
    32.     RpcSplit(obj, split, angle, hasAuthority, this.unitAttributes.splitPrefabFactor);
    33. }
    34.  
    35. [ClientRpc]
    36. public void RpcSplit(GameObject obj, GameObject split, float angle, bool hasAuthority, float splitFactor) {
    37.     //We do not call on NetworkServer methods here. This is used only to sync up with the original game unit for all clients.
    38.     //This includes adding the newly spawned game unit into the Selection Manager that handles keeping track of all game units.
    39.     GameUnit original = obj.GetComponent<GameUnit>();
    40.     GameUnit copy = split.GetComponent<GameUnit>();
    41.     Copy(original, copy);
    42.  
    43.     NavMeshAgent originalAgent = obj.GetComponent<NavMeshAgent>();
    44.     originalAgent.ResetPath();
    45.     NavMeshAgent copyAgent = split.GetComponent<NavMeshAgent>();
    46.     copyAgent.ResetPath();
    47.  
    48.     GameObject[] splitManagerGroup = GameObject.FindGameObjectsWithTag("SplitManager");
    49.     if (splitManagerGroup.Length > 0) {
    50.         for (int i = 0; i < splitManagerGroup.Length; i++) {
    51.             SplitManager manager = splitManagerGroup[i].GetComponent<SplitManager>();
    52.             if (manager != null && manager.hasAuthority == hasAuthority) {
    53.                 manager.splitGroupList.Add(new SplitGroup(original, copy, angle, splitFactor));
    54.                 if (manager.selectionManager == null) {
    55.                     GameObject[] objs = GameObject.FindGameObjectsWithTag("SelectionManager");
    56.                     foreach (GameObject select in objs) {
    57.                         SelectionManager selectManager = select.GetComponent<SelectionManager>();
    58.                         if (selectManager.hasAuthority) {
    59.                             manager.selectionManager = selectManager;
    60.                         }
    61.                     }
    62.                 }
    63.                 manager.selectionManager.allObjects.Add(split);
    64.             }
    65.         }
    66.     }
    67. }
    68.  
    69. private static void Copy(GameUnit original, GameUnit copy) {
    70.     copy.isSelected = original.isSelected;
    71.     copy.isSplitting = original.isSplitting;
    72.     copy.isMerging = original.isMerging;
    73.  
    74.     copy.transform.position = original.transform.position;
    75.     copy.transform.rotation = original.transform.rotation;
    76.     copy.transform.localScale = original.transform.localScale;
    77.     copy.oldTargetPosition = original.oldTargetPosition = -Vector3.one * 9999f;
    78.     copy.isDirected = original.isDirected = false;
    79.  
    80.     copy.level = original.level;
    81.     copy.previousLevel = original.previousLevel;
    82.     copy.maxHealth = original.maxHealth;
    83.     copy.currentHealth = original.currentHealth;
    84.     if (copy.currentHealth > copy.maxHealth) {
    85.         copy.currentHealth = copy.maxHealth;
    86.     }
    87.     if (original.currentHealth > original.maxHealth) {
    88.         original.currentHealth = original.maxHealth;
    89.     }
    90.     copy.recoverCooldown = original.recoverCooldown;
    91.     copy.recoverCounter = original.recoverCounter = 0;
    92.     copy.speed = original.speed;
    93.     copy.attackCooldown = original.attackCooldown;
    94.     copy.attackCooldownCounter = original.attackCooldownCounter = 0;
    95.     copy.attackPower = original.attackPower;
    96.  
    97.     copy.attributes = original.attributes;
    98.     copy.teamColorValue = original.teamColorValue;
    99.  
    100.     original.SetTeamColor(original.teamColorValue);
    101.     copy.SetTeamColor(copy.teamColorValue);
    102. }
    103.  

    And this is the Split Group struct the Split Manager handles:

    Code (CSharp):
    1. [System.Serializable]
    2. public struct SplitGroup {
    3.     public GameUnit ownerUnit;
    4.     public GameUnit splitUnit;
    5.     public float elapsedTime;
    6.     public Vector3 rotationVector;
    7.     public float splitFactor;
    8.     public Vector3 origin;
    9.  
    10.     public SplitGroup(GameUnit ownerUnit, GameUnit splitUnit, float angle, float splitFactor) {
    11.         this.ownerUnit = ownerUnit;
    12.         this.splitUnit = splitUnit;
    13.         this.elapsedTime = 0f;
    14.         this.origin = ownerUnit.gameObject.transform.position;
    15.         this.splitFactor = splitFactor;
    16.  
    17.         SpawnRange range = this.ownerUnit.GetComponentInChildren<SpawnRange>();
    18.         this.rotationVector = Quaternion.Euler(0f, angle, 0f) * (Vector3.one * range.radius);
    19.  
    20.         NavMeshAgent agent = this.ownerUnit.GetComponent<NavMeshAgent>();
    21.         if (agent != null) {
    22.             agent.ResetPath();
    23.             agent.Stop();
    24.         }
    25.         agent = this.splitUnit.GetComponent<NavMeshAgent>();
    26.         if (agent != null) {
    27.             agent.ResetPath();
    28.             agent.Stop();
    29.         }
    30.  
    31.         NetworkTransform transform = this.ownerUnit.GetComponent<NetworkTransform>();
    32.         if (transform != null) {
    33.             transform.transformSyncMode = NetworkTransform.TransformSyncMode.SyncNone;
    34.         }
    35.         transform = this.splitUnit.GetComponent<NetworkTransform>();
    36.         if (transform != null) {
    37.             transform.transformSyncMode = NetworkTransform.TransformSyncMode.SyncNone;
    38.         }
    39.     }
    40.  
    41.      //Again, nothing is involved in modifying anything related to the units. Current health, max health, nothing is involved.
    42.     public void Update() {
    43.         this.ownerUnit.isSelected = false;
    44.         this.splitUnit.isSelected = false;
    45.  
    46.         Vector3 pos = Vector3.Lerp(this.origin, this.origin + this.rotationVector, this.elapsedTime);
    47.         if (this.ownerUnit == null || this.ownerUnit.gameObject == null) {
    48.             this.elapsedTime = 1f;
    49.             return;
    50.         }
    51.         this.ownerUnit.gameObject.transform.position = pos;
    52.         pos = Vector3.Lerp(this.origin, this.origin - this.rotationVector, this.elapsedTime);
    53.         if (this.splitUnit == null || this.splitUnit.gameObject == null) {
    54.             this.elapsedTime = 1f;
    55.             return;
    56.         }
    57.         this.splitUnit.gameObject.transform.position = pos;
    58.     }
    59.  
    60.     public void Stop() {
    61.         NavMeshAgent agent = null;
    62.         if (this.ownerUnit != null) {
    63.             this.ownerUnit.isSplitting = false;
    64.             agent = this.ownerUnit.GetComponent<NavMeshAgent>();
    65.             if (agent != null) {
    66.                 agent.Resume();
    67.             }
    68.         }
    69.  
    70.         if (this.splitUnit != null) {
    71.             this.splitUnit.isSplitting = false;
    72.             agent = this.splitUnit.GetComponent<NavMeshAgent>();
    73.             if (agent != null) {
    74.                 agent.Resume();
    75.             }
    76.         }
    77.  
    78.         NetworkTransform transform = this.ownerUnit.GetComponent<NetworkTransform>();
    79.         if (transform != null) {
    80.             transform.transformSyncMode = NetworkTransform.TransformSyncMode.SyncTransform;
    81.         }
    82.         transform = this.splitUnit.GetComponent<NetworkTransform>();
    83.         if (transform != null) {
    84.             transform.transformSyncMode = NetworkTransform.TransformSyncMode.SyncTransform;
    85.         }
    86.     }
    87. };
    88.  

    I wanted to know what's the best way to prevent data corruption due to [SyncVar]. I figured if someone else takes a look at the codes, maybe I could've missed out on something, or hinted that I'm doing something wrong with my [SyncVars]?

    I know only the [Commands] can modify SyncVars on the servers and have the variables be synced across the network to the clients. But I just don't see how the clients received mangled data values from the server, if the connection is "localhost".
     
    Last edited: Nov 22, 2015
  2. seanr

    seanr

    Unity Technologies

    Joined:
    Sep 22, 2014
    Posts:
    669
    there are no syncvars in the code you posted..?
     
    MD_Reptile likes this.
  3. asperatology

    asperatology

    Joined:
    Mar 10, 2015
    Posts:
    976
    Sorry, here they are:

    Code (CSharp):
    1. public class GameUnit : NetworkBehaviour {
    2.     //Properties of a Game Unit
    3.     [SyncVar]
    4.     public bool isSelected;
    5.     [SyncVar]
    6.     public bool isDirected;
    7.     [SyncVar]
    8.     public bool isSplitting;
    9.     [SyncVar]
    10.     public bool isMerging;
    11.     [SyncVar]
    12.     public int currentHealth;
    13.     [Range(3, 100)]
    14.     [SyncVar]
    15.     public int maxHealth;
    16.     [Range(1f, 100f)]
    17.     [SyncVar]
    18.     public float attackPower;
    19.     [Range(0.001f, 10f)]
    20.     [SyncVar]
    21.     public float attackCooldown;
    22.     [SyncVar]
    23.     public float speed;
    24.     [Range(0.001f, 10f)]
    25.     [SyncVar]
    26.     public float recoverCooldown;
    27.     [SyncVar]
    28.     public int level;
    29.     [SyncVar]
    30.     public int previousLevel;
    31.     [SyncVar]
    32.     public int teamColorValue;
    33.  
    34.     public UnitAttributes attributes;
    35.     public float attackCooldownCounter;
    36.     public float recoverCounter;
    37.     public Color initialColor;
    38.     public Color takeDamageColor;
    39.     public GameUnit targetEnemy;
    40.     public GameObject selectionRing;
    41.  
    42.     public static bool once = false;
    43.  
    44.     //This variable keeps track of any changes made for the NavMeshAgent's destination Vector3.
    45.     //Doesn't even need to use [SyncVar]. Nothing is needed for tracking this on the server at all.
    46.     //Just let the clients (local and remote) handle the pathfinding calculations and not pass updated current transform position
    47.     //through the network. It's not pretty when you do this.
    48.     public Vector3 oldTargetPosition;
    49.     public Vector3 oldEnemyTargetPosition;
    50. }
     
  4. asperatology

    asperatology

    Joined:
    Mar 10, 2015
    Posts:
    976
    Here's an instance where [SyncVar] data corruption happens randomly. No changes in the code were made:



    This randomness is why it's so annoying. Hm.......
     
    Last edited: Nov 23, 2015
  5. seanr

    seanr

    Unity Technologies

    Joined:
    Sep 22, 2014
    Posts:
    669
    i cant tell from that gif what is supposed to happen or not happen? which SyncVar are you refering to?
     
  6. asperatology

    asperatology

    Joined:
    Mar 10, 2015
    Posts:
    976
    The GIF is supposed to show units splitting themselves into 2 of themselves, with both of them supposedly "duplicates." The left number shows the current health of the unit. The right number shows the max health of the unit. When the units split, they are said to "copy" the unit's current and max health from the original unit. The "copying" only occurs once the moment the unit becomes 2 units.

    However, for some reasons unexplained, the "copying" is valid during the animation sequence, but becomes invalid upon the end of the split sequence. Valid, as in the two units' current and max health are always 3/3. Invalid, as in the two units' current and max health may show up numbers other than 3/3 (3/25, or 28/3), and is inconsistent. The units themselves all start off with 3 hit points as the maximum, and 3 hit points being the current, because they are new. Splitting the units into 2 identical copies should not have 28 hit points as maximum health (units are not "leveled up" or "upgraded" at all, just a simple duplication) or have 25 hit points as current health more than the maximum health (25/3. Units should not have broken health hit points after a duplication.)

    The invalidity of the values of the current health and the max health of a duplicated unit is something I wished to fix.

    Code (CSharp):
    1.  
    2. public class GameUnit : NetworkBehaviour {
    3.      [SyncVar]
    4.      public int currentHealth;
    5.      [Range(3, 100)]
    6.      [SyncVar]
    7.      public int maxHealth;
    8.  
    9.      //Some other codes below...
    10.      //...
    11. }
    12.  
    These two variables are what is being "copied" to in the following [Command] function. The [Command] function, CmdSplit contains a static "Copy()" function, which copies all the [SyncVar] variables from the original to the copy. This "Copy()" is the only function I know of that has new values written to the [SyncVar] variables, with the new values being that of the original unit.

    Code (CSharp):
    1.  
    2. [Command]
    3. public void CmdSplit(GameObject obj, bool hasAuthority) {
    4.     GameUnit unit = obj.GetComponent<GameUnit>();
    5.     if (unit.attributes == null) {
    6.         if (this.unitAttributes != null) {
    7.             unit.attributes = this.unitAttributes;
    8.         }
    9.         else {
    10.             Debug.LogError("Definitely something is wrong here with unit attributes.");
    11.         }
    12.     }
    13.  
    14.     if (unit.isSplitting) {
    15.         return;
    16.     }
    17.  
    18.     unit.isSplitting = true;
    19.  
    20.     //This is profoundly one of the hardest puzzles I had tackled. Non-player object spawning non-player object.
    21.     //Instead of the usual spawning design used in the Spawner script, the spawning codes here are swapped around.
    22.     //In Spawner, you would called on NetworkServer.SpawnWithClientAuthority() in the [ClientRpc]. Here, it's in [Command].
    23.     //I am guessing it has to do with how player objects and non-player objects interact with UNET.
    24.     GameObject split = MonoBehaviour.Instantiate(this.gameUnitPrefab) as GameObject;
    25.     split.transform.position = obj.transform.position;
    26.  
    27.     GameUnit splitUnit = split.GetComponent<GameUnit>();
    28.     if (splitUnit != null) {
    29.         Copy(unit, splitUnit);
    30.     }
    31.  
    32.     NetworkIdentity managerIdentity = this.GetComponent<NetworkIdentity>();
    33.     NetworkServer.SpawnWithClientAuthority(split, managerIdentity.clientAuthorityOwner);
    34.     float angle = UnityEngine.Random.Range(-180f, 180f);
    35.  
    36.     RpcSplit(obj, split, angle, hasAuthority, this.unitAttributes.splitPrefabFactor);
    37. }
    38.  

    Let's say the original unit have 4 current health and 5 max health. Copy() will make the copy of the original unit have the same 4 current health hit points and 5 max health hit points. And that is it.

    Something tells me the Copy() is copying weird values from the original unit to the copy unit, and I can't explain why.

    Hope this makes everything clear.
     
  7. asperatology

    asperatology

    Joined:
    Mar 10, 2015
    Posts:
    976
    I am unhappy with the results, but this will do.

    Since the GameUnit class is derived from NetworkBehaviour, I thought I could use SetDirtyBit() to force the [SyncVar] to sync with the servers.

    Code (CSharp):
    1.     public void FixedUpdate() {
    2.         if (this.level == 1) {
    3.             if (this.currentHealth != 3 || this.maxHealth != 3 || this.currentHealth != 3 || this.maxHealth != 3) {
    4.                 this.SetDirtyBit(int.MaxValue);
    5.             }
    6.         }
    7.     }
    It doesn't work.

    The alternative is to "force refresh" the [SyncVar], also known as "flushing".

    Code (CSharp):
    1.     [ServerCallback]
    2.     private static void Increment(GameUnit unit) {
    3.         unit.isSelected = !unit.isSelected;
    4.         unit.isDirected = !unit.isDirected;
    5.         unit.isSplitting = !unit.isSplitting;
    6.         unit.isMerging = !unit.isMerging;
    7.         unit.currentHealth++;
    8.         unit.maxHealth++;
    9.         unit.attackPower++;
    10.         unit.attackCooldown++;
    11.         unit.speed++;
    12.         unit.recoverCooldown++;
    13.         unit.level++;
    14.         unit.previousLevel++;
    15.         unit.teamColorValue = (unit.teamColorValue + 1) % 3;
    16.     }
    17.  
    18.     [ServerCallback]
    19.     private static void Decrement(GameUnit unit) {
    20.         unit.isSelected = !unit.isSelected;
    21.         unit.isDirected = !unit.isDirected;
    22.         unit.isSplitting = !unit.isSplitting;
    23.         unit.isMerging = !unit.isMerging;
    24.         unit.currentHealth--;
    25.         unit.maxHealth--;
    26.         unit.attackPower--;
    27.         unit.attackCooldown--;
    28.         unit.speed--;
    29.         unit.recoverCooldown--;
    30.         unit.level--;
    31.         unit.previousLevel--;
    32.         unit.teamColorValue = (unit.teamColorValue + 2) % 3;
    33.     }
    Usage:

    Code (CSharp):
    1.     public void UpdateSplitGroup() {
    2.         if (this.splitGroupList != null && this.splitGroupList.Count > 0) {
    3.             for (int i = 0; i < this.splitGroupList.Count; i++) {
    4.                 SplitGroup group = this.splitGroupList[i];
    5.                 if (group.elapsedTime >= 1f) {
    6.                     group.Stop();
    7.                     Increment(group.ownerUnit);   //<-----------  HERE!
    8.                     Decrement(group.ownerUnit);  //<-----------  HERE!
    9.                     Increment(group.splitUnit);  //<-----------  HERE!
    10.                     Decrement(group.splitUnit);  //<-----------  HERE!
    11.                     if (group.splitUnit != null && !this.selectionManager.allObjects.Contains(group.splitUnit.gameObject)) {
    12.                         this.selectionManager.allObjects.Add(group.splitUnit.gameObject);
    13.                     }
    14.                     if (!this.selectionManager.allObjects.Contains(group.ownerUnit.gameObject)) {
    15.                         this.selectionManager.allObjects.Add(group.ownerUnit.gameObject);
    16.                     }
    17.                     this.removeList.Add(group);
    18.                 }
    19.                 else {
    20.                     //Some weird C# language design...
    21.                     group.Update();
    22.                     group.elapsedTime += Time.deltaTime / group.splitFactor;
    23.                     this.splitGroupList[i] = group;
    24.                 }
    25.             }
    26.         }
    27.  
    28.         if (this.removeList != null && this.removeList.Count > 0) {
    29.             foreach (SplitGroup group in this.removeList) {
    30.                 this.splitGroupList.Remove(group);
    31.             }
    32.             this.removeList.Clear();
    33.         }
    34.     }
    This will "flush" all the [SyncVar] in the GameUnit class, and will revert them back as it should be. I do see that it isn't 100% completely reliable. It only works for 50% of the times, and I could get myself lucky for getting a series of 50% chance of "working fine and well."

    I'm not happy with it, but when desperate times come around, there's nothing you can do about it except to "exploit" Unity engine.
     
    Last edited: Nov 23, 2015
  8. hippocoder

    hippocoder

    Digital Ape Moderator

    Joined:
    Apr 11, 2010
    Posts:
    27,807
    Sounds like your actual logic is broken, not syncvar. Are you certain you're routing syncvar changes via commands? Are they reliable QoS?
     
  9. asperatology

    asperatology

    Joined:
    Mar 10, 2015
    Posts:
    976
    The [SyncVar] data corruptions only happens on the client side. I'm very positive that I'm routing my syncvar changes through commands.

    Not sure about QoS, will have to check.

    EDIT:

    Mucking around with the QoS channel settings in the Network Manager doesn't fix the data corruptions.
     
  10. hippocoder

    hippocoder

    Digital Ape Moderator

    Joined:
    Apr 11, 2010
    Posts:
    27,807
    You'll have to report a bug.
     
  11. asperatology

    asperatology

    Joined:
    Mar 10, 2015
    Posts:
    976
    Alright. I'll be sure to report it with a link to this thread.
     
    hippocoder likes this.
unityunity