MirrorNetworking / Mirror

#1 Open Source Unity Networking Library
https://mirror-networking.com
MIT License
5.22k stars 772 forks source link

SyncVar does not sync if it's modified by reference #3129

Open PeturDarri opened 2 years ago

PeturDarri commented 2 years ago

Describe the bug If you pass a SyncVar field by reference (either with ref or out) and modify the value, the value will change locally but won't be synced to other clients.

[IMPORTANT] How can we reproduce the issue, step by step:

public class Enemy : NetworkBehaviour
{
    [SyncVar]
    public int health = 100;

    [Server]
    private void Update()
    {
        Randomize(out health);
    }

    private void Randomize(out int variable)
    {
        variable = Random.Range(0, 100);
    }
}
  1. Pass a SyncVar field by reference (out or ref).
  2. Assign a new value to the variable on the server.
  3. Observe that clients have not received the new value.

Expected behavior I expect the value of the field to be synced or for there to be an error when I attempt to pass a SyncVar by reference.

Desktop (please complete the following information):

Additional context This is also problematic for ref returns:

public class Enemy : NetworkBehaviour
{
    [SyncVar]
    public int health = 100;

    public ref int HealthProperty => ref health;

    [Server]
    private void Update()
    {
        HealthProperty = Random.Range(0, 100);
    }
}
imerr commented 2 years ago

This is somewhat expected SyncVars are "magic" in the sense that the weaver replaces code after compilation with a property and only detects changes via setting to that property for your example that might look something like this

    public int Networkhealth
    {
        get
        {
            return health;
        }
        [param: In]
        set
        {
            if (!NetworkBehaviour.SyncVarEqual(value, ref health))
            {
                int oldValue = health;
                SetSyncVar(value, ref health, 1uL);
                if (NetworkServer.localClientActive && !GetSyncVarHookGuard(1uL))
                {
                    SetSyncVarHookGuard(1uL, value: true);
                    OnChanged(oldValue, value);
                    SetSyncVarHookGuard(1uL, value: false);
                }
            }
        }
    }

I'm not sure it is possible to support the ref usage in this case

There should probably be an error raised from the weaver if you attempt to ref a syncvar, but I'm not sure how easy that would be to implement

PeturDarri commented 2 years ago

I expected this as well. I actually don't use Mirror myself but I was aware Mirror used IL weaving and noticed SyncVars are just regular fields so I suspected this would be problematic. I expected an error because I've seen people complain about weaver errors in Mirror, but when I didn't see one, I submitted this issue.

I don't think it's necessary to make this work, it should just raise an error. It should be pretty trivial to spot whenever a SyncVar field is being passed by reference. The IL weaver is already looking for ldflda instructions related to SyncVar fields for another purpose.

in and ref readonly are safe in this case though, the IL weaver should permit those.

MrGadget1024 commented 2 years ago

I'd think that'd work but spamming values into it in Update won't sync every change because it's too fast...it sends nothing at all?

PeturDarri commented 2 years ago

I'd think that'd work but spamming values into it in Update won't sync every change because it's too fast...it sends nothing at all?

This is a made up example, kept simple to keep the focus on the true cause of the problem. I've confirmed the issue by decompiling the assembly after Mirror's IL weaver has modified it. It clearly bypasses the generated syncing code:

public class Enemy : NetworkBehaviour
{
    [SyncVar]
    public int Health = 100;

    [Server]
    public void RandomizeHealth()
    {
        Health = Random.Range(0, 100);
    }

    [Server]
    public void RandomizeHealthRef()
    {
        ref var healthRef = ref Health;

        healthRef = Random.Range(0, 100);
    }
}

decompiled becomes:

public class Enemy : NetworkBehaviour
{
        [SyncVar]
    public int Health = 100;

    [Server]
    public void RandomizeHealth()
    {
        if (!NetworkServer.active)
        {
            Debug.LogWarning("[Server] function 'System.Void Enemy::RandomizeHealth()' called when server was not active");
            return;
        }

                // Health was replaced with NetworkHealth, which is correct.
        this.NetworkHealth = Random.Range(0, 100);
    }

    [Server]
    public void RandomizeHealthRef()
    {
        if (!NetworkServer.active)
        {
            Debug.LogWarning("[Server] function 'System.Void Enemy::RandomizeHealthRef()' called when server was not active");
            return;
        }

                // NetworkHealth is nowhere to be seen here, which means GeneratedSyncVarSetter is never called.
        ref int healthRef = ref this.Health;
        healthRef = Random.Range(0, 100);
    }

    private void MirrorProcessed()
    {
    }

    public int NetworkHealth
    {
        get
        {
            return this.Health;
        }
        [param: In]
        set
        {
            base.GeneratedSyncVarSetter<int>(value, ref this.Health, 1UL, null);
        }
    }

    public override bool SerializeSyncVars(NetworkWriter writer, bool forceAll)
    {
        bool result = base.SerializeSyncVars(writer, forceAll);
        if (forceAll)
        {
            writer.WriteInt(this.Health);
            return true;
        }
        writer.WriteULong(base.syncVarDirtyBits);
        if ((base.syncVarDirtyBits & 1UL) != 0UL)
        {
            writer.WriteInt(this.Health);
            result = true;
        }
        return result;
    }

    public override void DeserializeSyncVars(NetworkReader reader, bool initialState)
    {
        base.DeserializeSyncVars(reader, initialState);
        if (initialState)
        {
            base.GeneratedSyncVarDeserialize<int>(ref this.Health, null, reader.ReadInt());
            return;
        }
        long num = (long)reader.ReadULong();
        if ((num & 1L) != 0L)
        {
            base.GeneratedSyncVarDeserialize<int>(ref this.Health, null, reader.ReadInt());
        }
    }
}

Obviously no one is using ref locals like this, but it highlights the problem in a simple way. The same underlying issue applies to ref and out parameters and ref returns.

MrGadget1024 commented 2 years ago

I'll leave this open for @vis2k to look at to determine level of effort to solve.

miwarnec commented 2 years ago

nice find. weaver replaces all access to the variable. we never check for ref there, need to see which IL this generates and add it. should be doable, soon ish after more urgent bug fixes

miwarnec commented 2 years ago

first step: added a test to make sure it never happens again after fix.

miwarnec commented 2 years ago

[SyncVar]s are actually properties internally, generated by weaver. passing a property as ref is not supported by c#:

2022-04-01_23-57-06@2x

so we should probably detect that case with weaver and generate a compilation error instead of silently failing

miwarnec commented 2 years ago

IL for passing a value as ref:

        // Setter(out serverComponent.value);
        IL_0016: ldloc.0
        IL_0017: ldflda int32 Mirror.Tests.SyncVarAttributeTests.SyncVarSetByRef::'value'
        IL_001c: call void Mirror.Tests.SyncVarAttributeTests.SyncVarAttributeTest::'<SyncsIfSetByRef>g__Setter|14_0'(int32&)
miwarnec commented 2 years ago

forbidding ldflda for syncvars may not work. for example:

// syncproportions is a [SyncVar] struct
ldflda_Array = _syncProportions.Array;

IL:

IL_0008: ldarg.0
IL_0009: ldarg.0
IL_000a: ldflda valuetype Mirror.Tests.SyncVarAttributeTests.Proportions Mirror.Tests.SyncVarAttributeTests.ImerHook_Ldflda::_syncProportions
IL_000f: ldfld uint8[] Mirror.Tests.SyncVarAttributeTests.Proportions::Array
IL_0014: stfld uint8[] Mirror.Tests.SyncVarAttributeTests.ImerHook_Ldflda::ldflda_Array
miwarnec commented 2 years ago

this may not be detectable with weaver.

ref / out uses ldflda (load field address). but that's not used only for refs. it's also used for the code above.

=> disallowing ldflda for syncvars would not work. => scanning method parameters for 'out' does not work because we don't know if a syncvar is passed or not


only way to solve this seems to be to scan actual code to see if a syncvar is actually passed as 'ref' or 'out'. weaver can not do this. perhaps using roslyn source generators could solve this in the future. ... unless anyone has any other idea.


another option would be to force [SyncVar]s as properties. another option is to fully serialize all syncvars all the time some day. then we don't need the properties. another option is to enforce blittable so we can memcmp instead of dirty bits. which C# would then disallow from passing as refs.

miwarnec commented 2 years ago

WIP for ldflda detection is in fix_3129 branch btw

PeturDarri commented 2 years ago

=> disallowing ldflda for syncvars would not work.

In my attempt to fix the problem, I tried disallowing all ldflda usages for SyncVars. That was problematic because Mirror's own generated IL is using ldflda. So I added a HashSet<MethodDefinition> to SyncVarAccessLists of methods that are allowed to use ldflda for SyncVars and added all the relevant generated methods.

That fixed most of the errors, but there were some tests that were still causing errors, like this one: https://github.com/vis2k/Mirror/blob/0b4787d3d933f804885bf68ee7306109a218d199/Assets/Mirror/Tests/Editor/SyncVarAttributeHookTest.cs#L143-L157

Currently this seems to just silently fail, like ref SyncVars. Shouldn't this cause an error as well?


The ImerHook_Ldflda is a tricky case, and yeah I can't see any way to fix it without doing more detailed analysis of exactly what happens to the SyncVar address in the stack. That's a rabbit hole that goes very deep. This kind of analysis would also be needed to allow readonly references, because it's all just ldflda in IL.


another option is to fully serialize all syncvars all the time some day.

I did consider this as well. One solution could be to just set a SyncVar as dirty whenever it's used with ldflda. I assume it's serialized sometime later after being dirtied, so the value should already be set by that time. This solution would also automatically make the DavidHook test work instead of silently failing.

The downside is it would cause unintentional serialization in cases where it's only being read (but that's still better than serializing always).