-
Notifications
You must be signed in to change notification settings - Fork 81
Description
This issue is more an explainer on how one would get started on this feature since I implemented it successfully in my WIP custom modloader, but it is something Doorstop could have. I likely won't be the one that does it, but I wanted to share what I learned by doing it myself.
Explanation
The feature allows the game to behave similarly to how a real Unity dev build would when trying to connect to it via an IDE. IDE typically have logic to automatically detect the location of a Unity player and to connect to it immediately without having the user input the IP and port manually. This is done by sending a UDP packet periodically to a multicast group every second.
Message details
The details of how this works are surprisingly not as secretive as one might think. There's actually a lot of info about this coming from JetBrains's Resharper code where part of its Unity support is open source. More specifically, this file ended up being a goldmine with information in comments about how this works: https://github.com/JetBrains/resharper-unity/blob/f995358f1eabc57df421af954880592f1c26beff/rider/src/main/kotlin/com/jetbrains/rider/plugins/unity/run/UnityPlayerListener.kt
For the sake of redundancy, here's the most interesting comment about how the message is formed with an example and details of each fields:
// E.g.:
// [IP] 10.211.55.4 [Port] 55376 [Flags] 3 [Guid] 1410689715 [EditorId] 1006284310 [Version] 1048832 [Id] WindowsPlayer(PARALLELS) [Debug] 1 [PackageName] WindowsPlayer [ProjectName] GemShader is awesome
// As far as I can tell:
// * [IP] %s - where the process is running. On iPhone (and perhaps other devices) this can be the mobile data
// IP, which might be unreachable from this subnet, so be prepared to use the IP address from the
// UDP packet instead
// * [Port] %u - NOT the debugging port. I think this port connects to the player (to e.g. get logs)
// * [Flags] %u - settings for the editor. Don't know what the values are
// * [Guid] %u - random number. Consistent only for the lifetime of the player. If no debugging port is found as
// part of `id`, then the debugging port is `guid % 1000 + 56000` (which belies the part that it's
// a random number, and is more likely that if no debugging port is specified, this must be a PID)
// * [EditorId] %u - random number representing an ID of the editor instance that built this player. Consistent
// for the lifetime of the editor. Will also be used by any other player built by the same
// editor instance
// * [Version] %d - static value. Never been changed
// * [Id] %s - a textual identifier, e.g. "OSXPlayer(Matts.MacBookPro.Local)". May also include debugging port,
// e.g. `iPhonePlayer(Matts.iPhone7):56000`
// * [Debug] %d - 0 or 1 to show if debugging is enabled. Will not be able to attach if this is 0
// * [PackageName] %s - the type of the player, e.g. `iPhonePlayer` or `WindowsPlayer`. Could be used as as a
// "type" field. This is not present in all messages, so I guess it was added in a specific
// version of Unity, but don't know which
// * [ProjectName] %s - same as PlayerSettings.productName. Added in Unity 2019.3a6
Obviously, one can also verify these information with Ghidra, but I did myself and as far as I can tell, all of the above is very accurate or very close to be accurate.
I only checked 2018.4.12f1 using Rider (I need to do Visual Studio tests at a later time), but on my end, here's what I found wtih Ghidra that isn't mentioned above:
- The port is random between 55000 inclusive and 55512 exclusive
- The behavior of whether to take the IP from message or from UDP source packet seems to be IDE specific, but Rider seems to do the former if bit 8 of the [Flags] is set. I have yet to check if this is enough for Visual Studio, but it works perfectly for Rider (this is useful since Doorstop wants to set its custom IP from user settings). Interestingly, not all Unity player supports bit 8, but we don't care because what matters is IDE supports it so we can fake support if we send our own message
- The [GUID] is a random 32 bit integers as confirmed with Ghidra
- The version number 1048832 is hardcoded as confirmed with Ghidra
- The [Id] is formated like such: platform(hostname):port where:
- platform is a name that identifies the player platform such as "WindowsPlayer", "LinuxPlayer", "iPhonePlayer", etc...
- hostname is the hostname of the machine the player is running on, but each spaces (" ") is replaced with underscores ("_")
- port is the ACTUAL debugging port so Doorstop will need to put the value from user settings. This is the case regardless of where the IP is taken from
- [PackageName] is hardcoded, but it seems to always be the "platform" part of the [Id] mentioned above
- [ProjectName] is interesting because IDEs seems to be equipped to handle this, but it's not something the player NEEDS to send because it was added from a certain unity version onward so it's optional. It means that Doorstop could send this field and it should be parsed by IDEs correctly even if the original Unity player did not support it!
- There's always a null terminator at the end of the message, this is hardcoded as confirmed with Ghidra
IP and port
As for the address and port to use for the multicasting, on my unity version, it's hardcoded to be 225.0.0.222 on port 54997. The Resharper code above suggests it could use other ports. I have not confirmed this (2018.4.12f1 seems to always use 54997), but the code I linked above indicates these could be used too:
- 34997
- 57997
- 58997
So Doorstop would need to handle this case.
Other useful stuff
Lastly, I am linking my implementation I did on my custom modloader. It's done in C# using native AOT, but I link it here because I took the time to annotate stuff with comments in case it ends up helping someone implement such a feature: https://github.com/aldelaro5/VenusRootLoader/blob/main/VenusRootLoader.Bootstrap/Unity/PlayerConnectionDiscovery.cs
Oh and one more thing: while sending a UDP packet every second might sound like a lot for Doorstop to do, it's actually mandatory: Rider will think a player disappeared if it can't find its listing after 3 attempts and it too polls every second so you have a 3 seconds timeout. I don't know if Visual Studio is this strict, but just that is reason enough to know you can't really cheat this timing.
A summary
To summarise, you have to send a UDP message every second on 225.0.0.222 with the port being 54997, 34997, 57997 or 58997 (might change depending on the Unity version). The message is formatted such that it contains multiple fields that are space (" ") separated, each with the format "[Name] value". Here are the fields and the value a bootstrap needs to have:
- [IP]: Should be the one that comes from bootstrap config, but you need to have bit 8 of the [Flags] set. NOTE: If it is 127.0.0.1, then bit 8 of the [Flags] should NOT be set because it's not possible for the IDE to connect to localhost like this, but it is possible for the IDE to connect to the UDP source packet.
- [Port]: Should be random using the scheme I mentioned above
- [Flags]: Should be 0 or 8 depending if the IP of the player should come from its UDP source IP or the [IP] value in the message
- [Guid]: Should be a random 32 bit number
- [EditorId]: I would just send 0 to this one. It's normally determined by the Editor which places a boot.config value and it's probably random there, but it's more that since it's meant to identify a build from the editor side, it's not something bootstrap needs to care about. I don't know the randomnisation scheme the editor uses on this one so you could send a random value to replicate it, but I couldn't find that doing so would make any differences
- [Version]: Should always be 1048832
- [Id]: Should be formatted as mentioned above. The platform is something the bootstrap should know since it's already OS specific and the hostname is obtainable. It's unlknown if you can just put whatever you want, but I suggest you put the real value because IDEs will show it in the listing so we're assuming here that the user would see a hostname they recognise for this if it's correct. NOTE: This MUST contain the desired port from user config, Unity normally generates a random one.
- [Debug]: Has to be 1. If it's 0, you will see the player in the listing, but it will say that it doesn't support script debugging and refuse to connect.
- [PackageName]: I would handle it the same way than the [ID] field, just with the platform name part of it
- [ProjectName]: This doesn't HAVE to be sent, but considering IDEs parses this, I think it can be an interesting thing to have like it could say "Doorstop" or something and Rider would show it in the listing. Still, it's optional.
All of the above is relatively straight forward (mostly configuring the socket, but my implementation does that based on what I saw on Ghidra). The only thing to keep in mind is to not forget to honor DnSpy's debugging with its environment variables, but this is something Doorstop already handles on the Mono debugging side so it shouldn't be an issue.
Dev player builds
There's one edgecase: dev builds. The problem with dev player builds is they spin their own socket and performs the above, but that would interfere with Doorstop if it were to create its own socket. I only see 2 ways to handle this:
- Don't spin your socket: this works and is the least intrusive, but it also means the user config (and DnSpy's env var) cannot be honored. There's also no way to know the IP and port Unity will decide in advance because as mentioned previously, the debugging port is random. I haven't looked much into making this works, but I was sceptical. I know there is an IP and port that comes from boot.config, but the IP appears to be the message one (which might not be useful) and the port is for the [Port] field of the message which isn't the debugging port (that one comes from part of the [ID] field)
- PltHook on Unity's "send" call to hijack the message: This is intrusive, but I went with this personally because it ended up not only working, but give a consistent experience with non dev build: you're already hijacking mono's init anyway with your own IP / port so this forces Unity to honor it.