Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Unity.Pipeline.CodeReload;
|
||||
using UnityEditor;
|
||||
using UnityEditor.Networking.PlayerConnection;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Networking.PlayerConnection;
|
||||
|
||||
namespace Unity.Pipeline.Editor
|
||||
{
|
||||
/// <summary>
|
||||
/// Editor side of code-reload push signing. Owns the per-player session table (nonce announced by
|
||||
/// each player's HandshakeMsg) and produces signed <see cref="PushEnvelope"/>s with the project key
|
||||
/// (<c>Library/Pipeline/signing.key</c>, created at build time — never here: a key no build has
|
||||
/// baked would sign pushes nothing accepts, so its absence should say "build first").
|
||||
///
|
||||
/// The session table is editor-memory only, so every domain reload empties it. Recovery is the
|
||||
/// handshake protocol: on load we broadcast RequestHandshake; a push whose target has no session yet is
|
||||
/// deferred a couple of seconds while a directed RequestHandshake round-trips (see
|
||||
/// <see cref="PushOrDefer"/>) — watch-mode saves right after a script recompile keep working.
|
||||
///
|
||||
/// Counters only need to be strictly increasing per player session and are bound to the signed
|
||||
/// nonce, so they are seeded from UTC ticks: a fresh editor session (or another machine holding a
|
||||
/// copy of the key) continues above whatever a previous editor session used, without the player
|
||||
/// having to report where the count stands.
|
||||
/// </summary>
|
||||
[InitializeOnLoad]
|
||||
internal static class PushSigner
|
||||
{
|
||||
private sealed class Session
|
||||
{
|
||||
public byte[] Nonce;
|
||||
public long Counter;
|
||||
public string Fingerprint; // key fingerprint the player expects ("" = no key baked)
|
||||
public int ProtocolVersion;
|
||||
}
|
||||
|
||||
// How long a push waits for the player's handshake before it is dropped. An idle IL2CPP
|
||||
// player on the same machine took longer than 2 s to answer the first request after the
|
||||
// editor's domain reload (verified live), which silently lost the first push.
|
||||
private const double DeferSeconds = 10.0;
|
||||
|
||||
private static readonly Dictionary<int, Session> s_Sessions = new Dictionary<int, Session>();
|
||||
private static readonly List<(double deadline, int playerId, byte[] payload, string desc)> s_Deferred =
|
||||
new List<(double, int, byte[], string)>();
|
||||
private static bool s_DeferPumpHooked;
|
||||
private static bool s_Registered;
|
||||
|
||||
static PushSigner()
|
||||
{
|
||||
// EditorConnection is not reliably initializable during InitializeOnLoad itself.
|
||||
EditorApplication.delayCall += RegisterAndRequestHandshakes;
|
||||
AssemblyReloadEvents.beforeAssemblyReload += Unregister;
|
||||
}
|
||||
|
||||
private static void RegisterAndRequestHandshakes()
|
||||
{
|
||||
try
|
||||
{
|
||||
EditorConnection.instance.Initialize();
|
||||
EditorConnection.instance.Register(PipelineCodeReloadConnect.HandshakeMsg, OnHandshake);
|
||||
s_Registered = true;
|
||||
|
||||
// A domain reload just wiped the session table; ask every connected player to
|
||||
// re-announce so it is warm again before anyone pushes.
|
||||
var players = EditorConnection.instance.ConnectedPlayers;
|
||||
if (players != null && players.Count > 0)
|
||||
EditorConnection.instance.Send(PipelineCodeReloadConnect.RequestHandshakeMsg, RequestPayload());
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[PushReload] Could not register the push-signing handshake handler: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static void Unregister()
|
||||
{
|
||||
s_Deferred.Clear();
|
||||
if (s_DeferPumpHooked)
|
||||
{
|
||||
EditorApplication.update -= PumpDeferred;
|
||||
s_DeferPumpHooked = false;
|
||||
}
|
||||
if (!s_Registered) return;
|
||||
try { EditorConnection.instance.Unregister(PipelineCodeReloadConnect.HandshakeMsg, OnHandshake); }
|
||||
catch { /* connection may already be gone during teardown */ }
|
||||
s_Registered = false;
|
||||
}
|
||||
|
||||
// Non-empty payload: some transport paths drop zero-length messages.
|
||||
private static byte[] RequestPayload() => new byte[] { PushEnvelope.ProtocolVersion };
|
||||
|
||||
// Main thread (EditorConnection dispatches there, same as the ack handler in PushReloadCommand).
|
||||
private static void OnHandshake(MessageEventArgs args)
|
||||
{
|
||||
if (!PipelineCodeReloadConnect.TryDecodeHandshake(args.data, out var version, out var fingerprint, out var nonce))
|
||||
{
|
||||
Debug.LogWarning($"[PushReload] Undecodable handshake from player {args.playerId} — its pushes will fail.");
|
||||
return;
|
||||
}
|
||||
if (nonce == null || nonce.Length != PushEnvelope.NonceSize)
|
||||
{
|
||||
Debug.LogWarning($"[PushReload] Handshake from player {args.playerId} carries a bad session nonce — its pushes will fail.");
|
||||
return;
|
||||
}
|
||||
|
||||
bool isNew = !s_Sessions.TryGetValue(args.playerId, out var existing)
|
||||
|| !NoncesEqual(existing.Nonce, nonce);
|
||||
s_Sessions[args.playerId] = new Session
|
||||
{
|
||||
Nonce = nonce,
|
||||
Counter = s_Sessions.TryGetValue(args.playerId, out var prev) ? prev.Counter : 0,
|
||||
Fingerprint = fingerprint ?? "",
|
||||
ProtocolVersion = version,
|
||||
};
|
||||
|
||||
if (isNew)
|
||||
{
|
||||
var local = LocalFingerprint();
|
||||
var expected = string.IsNullOrEmpty(fingerprint) ? "none (no key baked!)" : fingerprint;
|
||||
var session = $"player {args.playerId} session: protocol v{version}, expects key {expected}";
|
||||
if (local != null && !string.IsNullOrEmpty(fingerprint) && local != fingerprint)
|
||||
// Actionable right now — every push to this player would be rejected — so it stays
|
||||
// a warning of its own rather than waiting for the first reload.
|
||||
Debug.LogWarning($"[PushReload] {session} — MISMATCH with this machine's key {local}: rebuild the player, " +
|
||||
$"or copy Library/Pipeline/{PushSigningKey.KeyFileName} from the machine that built it.");
|
||||
else
|
||||
// A player connecting is only interesting once something is pushed to it; a
|
||||
// connected dev player with the watcher off used to log this on every connect.
|
||||
CodeReloadRegistry.NoteForFirstReload(session);
|
||||
}
|
||||
|
||||
PumpDeferred(); // a deferred push may have been waiting exactly for this handshake
|
||||
}
|
||||
|
||||
private static bool NoncesEqual(byte[] a, byte[] b)
|
||||
{
|
||||
if (a == null || b == null || a.Length != b.Length) return false;
|
||||
for (int i = 0; i < a.Length; i++)
|
||||
if (a[i] != b[i]) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
internal static bool HasSession(int playerId) => s_Sessions.ContainsKey(playerId);
|
||||
|
||||
internal static string LocalFingerprint() =>
|
||||
PushSigningKey.TryLoad(PushSigningKey.DefaultDirectory, out var key)
|
||||
? PushEnvelope.Fingerprint(key)
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Sign <paramref name="payload"/> for one player and send it, or — if that player's handshake
|
||||
/// has not arrived (typical right after an editor domain reload) — request one and defer the
|
||||
/// send for up to a couple of seconds. Returns a short status for the caller's summary line;
|
||||
/// deferred/failed details are also logged here.
|
||||
/// </summary>
|
||||
internal static PushSendStatus PushOrDefer(int playerId, byte[] payload, string desc)
|
||||
{
|
||||
if (s_Sessions.ContainsKey(playerId))
|
||||
return SignAndSend(playerId, payload, desc) ? PushSendStatus.Sent : PushSendStatus.Failed;
|
||||
|
||||
try
|
||||
{
|
||||
EditorConnection.instance.Send(PipelineCodeReloadConnect.RequestHandshakeMsg, RequestPayload(), playerId);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogWarning($"[PushReload] Could not request a handshake from player {playerId}: {ex.Message}");
|
||||
}
|
||||
|
||||
s_Deferred.Add((EditorApplication.timeSinceStartup + DeferSeconds, playerId, payload, desc));
|
||||
if (!s_DeferPumpHooked)
|
||||
{
|
||||
EditorApplication.update += PumpDeferred;
|
||||
s_DeferPumpHooked = true;
|
||||
}
|
||||
return PushSendStatus.Deferred;
|
||||
}
|
||||
|
||||
private static bool SignAndSend(int playerId, byte[] payload, string desc)
|
||||
{
|
||||
var session = s_Sessions[playerId];
|
||||
|
||||
if (!PushSigningKey.TryLoad(PushSigningKey.DefaultDirectory, out var privateKey))
|
||||
{
|
||||
Debug.LogError($"[PushReload] {desc}: no push-signing key at Library/Pipeline/{PushSigningKey.KeyFileName}. " +
|
||||
"It is created at build time — build the player once on this machine, or copy the key file from the machine that built it.");
|
||||
return false;
|
||||
}
|
||||
|
||||
var local = PushEnvelope.Fingerprint(privateKey);
|
||||
if (string.IsNullOrEmpty(session.Fingerprint))
|
||||
{
|
||||
Debug.LogError($"[PushReload] {desc}: player {playerId} has no baked push key and rejects all pushes. " +
|
||||
"Rebuild with the Pipeline build processor enabled (it bakes the key into RuntimePipelineBuildInfo).");
|
||||
return false;
|
||||
}
|
||||
if (session.Fingerprint != local)
|
||||
{
|
||||
Debug.LogError($"[PushReload] {desc}: player {playerId} expects key {session.Fingerprint}, this machine has {local}. " +
|
||||
$"Rebuild the player, or copy Library/Pipeline/{PushSigningKey.KeyFileName} from the machine that built it.");
|
||||
return false;
|
||||
}
|
||||
|
||||
// Strictly increasing per session; UTC-tick seeding keeps a fresh editor domain (counter
|
||||
// state lost) above anything a previous editor session already used.
|
||||
session.Counter = Math.Max(session.Counter + 1, DateTime.UtcNow.Ticks);
|
||||
|
||||
try
|
||||
{
|
||||
var envelope = PushEnvelope.Sign(privateKey, session.Nonce, session.Counter, payload);
|
||||
EditorConnection.instance.Send(PipelineCodeReloadConnect.ApplyReloadMsg, envelope, playerId);
|
||||
Commands.PushReloadCommand.TrackAck(desc + $" → player {playerId}");
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.LogError($"[PushReload] {desc}: signing/sending to player {playerId} failed: {ex.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static void PumpDeferred()
|
||||
{
|
||||
if (s_Deferred.Count == 0)
|
||||
{
|
||||
if (s_DeferPumpHooked)
|
||||
{
|
||||
EditorApplication.update -= PumpDeferred;
|
||||
s_DeferPumpHooked = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
double now = EditorApplication.timeSinceStartup;
|
||||
// Drain in insertion (FIFO) order. The counter is assigned at send time, so sending the
|
||||
// oldest ready push first keeps the newest push on the highest counter; draining
|
||||
// newest-first would sign a later push with a lower counter and the player would then
|
||||
// apply the stale earlier override last. Rebuild the queue with the entries that were
|
||||
// neither sent nor expired, preserving their order.
|
||||
var remaining = new List<(double deadline, int playerId, byte[] payload, string desc)>(s_Deferred.Count);
|
||||
foreach (var entry in s_Deferred)
|
||||
{
|
||||
var (deadline, playerId, payload, desc) = entry;
|
||||
|
||||
if (s_Sessions.ContainsKey(playerId))
|
||||
{
|
||||
if (SignAndSend(playerId, payload, desc))
|
||||
Debug.Log($"[PushReload] {desc}: deferred push sent to player {playerId} (handshake arrived).");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (now >= deadline)
|
||||
{
|
||||
Debug.LogWarning($"[PushReload] {desc}: no handshake from player {playerId} within {DeferSeconds:0}s — push dropped. " +
|
||||
"Is the player build made with a signing-aware com.unity.pipeline package? Reconnect or restart the player and retry.");
|
||||
continue;
|
||||
}
|
||||
|
||||
remaining.Add(entry);
|
||||
}
|
||||
s_Deferred.Clear();
|
||||
s_Deferred.AddRange(remaining);
|
||||
}
|
||||
}
|
||||
|
||||
internal enum PushSendStatus
|
||||
{
|
||||
Sent,
|
||||
Deferred,
|
||||
Failed,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user