Initial commit: AIGame Unity client with URP, HybridCLR, YooAsset and tooling.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
udx
2026-09-21 14:21:07 +08:00
co-authored by Cursor
commit f3f66ab063
931 changed files with 112366 additions and 0 deletions
@@ -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,
}
}