using System.Buffers.Binary; using System.Collections.Generic; using System.Text; using Ananta.SDK.Rpc; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; namespace Ananta.Server.Handlers.Game; /// /// 音乐 / 演出线三条玩法的服务端闭环 —— 2026-10-04: /// /// 1. Livehouse 音游(节奏打击,引擎 MusicGameManager.lua) /// 2. PartyDance 舞池(同一套音游引擎,LivehouseConfig.Use == Party 那 5 首) /// 3. PartyLive 派对直播(点赞 / 礼物 / 弹幕,PartyLiveStreamPanelStore.lua) /// /// /// /// ★★ 接之前这三条线全套落在空包兜底里(DefaultReturnCatalog4229938.cs): /// 63885626 → [0xFF,0x01] list7-empty、63848409 → [0xFF,0x00] ReadBoolean、 /// 其余 shape="void"。症状不是报错,而是静默没内容: /// · 曲目列表恒空 ⇒ 面板上的等级打点永远是 0,重进清零; /// · FirstPlay 恒 false ⇒ 首次引导 timeline 永远不播; /// · 舞会三连里 AskStartPartyDance 的 Callback 什么都不做,客户端在等 /// SyncPartyDanceStart 推送 ⇒ 不推 = 点了没反应; /// · 直播间 UI 自己不发 RPC,数据全靠 SyncPartyResponse(68456156) ⇒ 从不推 = 空直播间。 /// /// /// /// ⛑ 硬规则 239 —— 每个契约都对照客户端自己的反序列化器核过,不是照 dump.cs 猜: /// Drmk.Proxy\proxy\lua\RPCDeserializeAuto.lua 的 Auto.Reader[571] / /// [568] / [428] / [429] / [1123], /// 参数形状对照 _lua_all\lua\LuaGen\AutoGen\ClientToGameDelegate.lua 的 *_Serializer。 /// /// /// ⛑ 回滚:从 GameRouter.cs 的 Enabled4229938MethodIds 里删掉对应行即可 /// (handler 不注册 = 回到中性兜底),本文件留着无害。 /// internal sealed partial class GameRouter { // --------------------------------------------------------------------------------------- // 1. Livehouse 音游 // // 客户端整条链(逐行核过,非推断): // 进 Sonic Boom(IndoorId 5) / 万能旋钮(274) 的室内扇区 → 选曲面板 // LivehouseSelectPanelStore.lua:291 OnPlayBtn // · LivehouseConfig.DialogBeforeTimelineSingle 命中 ⇒ 先播对话,DialogEnd 再进 // · 否则直接 PlayLiveHouseV2 // MusicGameManager.PlayLiveHouseV2(:324) → StartGame(:371) // → CheckShow(LIVEHOUSE_GAME_PANEL) + PlayGameMainTimeline(:564) // → 未显式跳过 intro 时先 AskQueryLiveHouseInfo():FirstPlay 为真才播 intro timeline // → LiveHouseMusicStart(musicId, inviteNpcId, difficulty) ← NOTIFY // → 打完 SetLiveHouseMusicResult(musicId, {P,G,M,S,percent}, inviteNpcId) ← INVOKE // → SetRecordMusicInfo + PlayLivehouseEndTimeline(Good / BadEnding 对话) // 开局前勾了「金手指」⇒ 先 AskLiveHouseUseItem(musicId) 再 StartGame。 // --------------------------------------------------------------------------------------- /// 取登录后才能拿到的 WorldEntryState;还没建好就是 null(按新账号处理)。 private WorldEntryState? LivehouseState(UxRpcMessage msg) => msg.Context is null ? null : GetWorldState(msg.Context); // AskQueryLiveHouseInfo (63848409) — Invoke ; 无参 ; return: Complex{ bool FirstPlay } // // 「要不要播首次引导」的唯一判据。第一次答 true,之后恒 false,由 // LivehouseProgressStore 把这个 latch 落进 config/player-progress.json。 [Handler(MethodId.AskQueryLiveHouseInfo, HandlerPacketKind.Invoke)] private Task AskQueryLiveHouseInfo4229938(Connection conn, UxRpcMessage msg) { var state = LivehouseState(msg); var firstPlay = state is not null && LivehouseProgressStore.TakeFirstPlay(state); conn.Log.Info($"[LIVEHOUSE] AskQueryLiveHouseInfo -> FirstPlay={firstPlay}"); return conn.ReturnAsync(msg, new LiveHouseQueryInfoPush { FirstPlay = firstPlay }); } // AskLiveHouseMusicList (63885626) — Invoke ; 无参 ; return: List7Bit // // 客户端把整张表灌进 gMusicGameManager.RecordMusicInfo,之后 GetScoreLevel 才显示 // 非最低档评级。空列表客户端不报错,只是等级打点全 0 —— 别指望有日志线索。 [Handler(MethodId.AskLiveHouseMusicList, HandlerPacketKind.Invoke)] private Task AskLiveHouseMusicList4229938(Connection conn, UxRpcMessage msg) { var state = LivehouseState(msg); var records = state is null ? new Dictionary() : LivehouseProgressStore.Snapshot(state); var list = new List(); foreach (var (musicId, record) in records) { if (musicId == 0 || record is null) continue; // 顺序必须严格是 Perfect / Great / Miss / Special(见契约文件的类注释)。 list.Add(new MusicClientInfoPush { MusicId = musicId, RecordInfo = new List { record.Perfect, record.Great, record.Miss, record.Special, }, AlreadyReward = record.AlreadyReward, }); } conn.Log.Info($"[LIVEHOUSE] AskLiveHouseMusicList -> {list.Count} 首有成绩"); return conn.ReturnAsync(msg, list); } // SetLiveHouseMusicResult (63723141) — Invoke ; args: u32 musicId, list7bit counts, u32 npcId // // 唯一的成绩写入路径。客户端实际给 5 项 {Perfect, Great, Miss, Special, floor(percent)} // (MusicGameManager.lua:264-270),我们只需要前 4 项。 [Handler(MethodId.SetLiveHouseMusicResult, HandlerPacketKind.Invoke)] private Task SetLiveHouseMusicResult4229938(Connection conn, UxRpcMessage msg) { var body = msg.Body; uint musicId = 0; uint npcId = 0; var counts = new List(); var pos = 0; if (body.Length >= 4) { musicId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); pos += 4; } // ReadList7Bit:长度是 varint,空表写 0x00;紧跟着 n 个裸 u32。 if (body.Length > pos) { var n = body[pos]; pos += 1; for (var i = 0; i < n && pos + 4 <= body.Length; i++) { counts.Add(BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4))); pos += 4; } } if (body.Length >= pos + 4) npcId = BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(pos, 4)); var state = LivehouseState(msg); if (state is null) { conn.Log.Warn($"[LIVEHOUSE] SetLiveHouseMusicResult musicId={musicId} 无 world state ⇒ 本次不落盘"); return conn.ReturnEmptyOkAsync(msg); } LivehouseProgressStore.Record(state, musicId, counts); conn.Log.Info( $"[LIVEHOUSE] result musicId={musicId} npcId={npcId} counts={counts.Count} " + $"P={U(counts, 0)} G={U(counts, 1)} M={U(counts, 2)} S={U(counts, 3)}"); // 落盘走 PlayerProgressStore.Save 的既有 tick(Save 读的就是 WorldEntryState)。 return conn.ReturnEmptyOkAsync(msg); } // LiveHouseMusicStart (63293678) — Notify ; args: u32 musicId, u32 npcId, u32 difficulty // LiveHouseMusicInterrupt (63752208)— Notify ; args: u32 musicId, u32 npcId // // 两条都只是「服务端知道一下就够了」:客户端既不 await,回调里也没有业务分支。 // 只落日志(将来要做局数统计时这份日志就是现成素材,同硬规则 112 的做法)。 [Handler(MethodId.LiveHouseMusicStart)] [Handler(MethodId.LiveHouseMusicInterrupt)] private Task LiveHouseMusicNotify4229938(Connection conn, UxRpcMessage msg) { var body = msg.Body; var musicId = body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(0, 4)) : 0u; var npcId = body.Length >= 8 ? BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(4, 4)) : 0u; // Notify 的 handler 不需要回包,ReturnEmptyOkAsync 对 Notify 无意义(第三条坑见铁律 4)。 var extra = msg.MethodId == MethodId.LiveHouseMusicStart && body.Length >= 12 ? $" difficulty={BinaryPrimitives.ReadUInt32LittleEndian(body.AsSpan(8, 4))}" : string.Empty; conn.Log.Info($"[LIVEHOUSE] {msg.MethodId} musicId={musicId} npcId={npcId}{extra}"); return Task.CompletedTask; } // AskLiveHouseUseItem (63665380) — Invoke ; args: u32 musicId ; return: void // // 「金手指」(强制全 Perfect)的道具消耗请求 —— LivehouseSelectPanelStore.lua:320。 // ⛑ 客户端自己的前置检查才是权威:面板在道具不足时先弹 // MessageConfig.LivehouseGoldFinger 而不会走到这里(:322)。 // ⇒ 服务端再扣一次是双重扣减;这里只放行,扣减交给客户端驱动的道具系统。 [Handler(MethodId.AskLiveHouseUseItem, HandlerPacketKind.Invoke)] private Task AskLiveHouseUseItem4229938(Connection conn, UxRpcMessage msg) { var musicId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0, 4)) : 0u; conn.Log.Info($"[LIVEHOUSE] AskLiveHouseUseItem musicId={musicId} -> 放行(扣减由客户端道具系统负责)"); return conn.ReturnEmptyOkAsync(msg); } // --------------------------------------------------------------------------------------- // 2. PartyDance 舞池 // // ★★ 关键:契约 SyncPartyDanceStartPush / SyncPartyDanceEndPush 早就写好了 // (PartyWireContracts.cs:306 / :356),但全仓零使用 ⇒ 舞会从来没真正开始过。 // // 客户端三连(ChooseDanceMusicPanelStore.lua:343-384 RequestStartPartyDance): // AskSelectPartyDanceSong(musicId) → AskSelectPartyDanceMode(mode) → AskStartPartyDance() // ⛑ 规则 257:那三处 `if err == MessageConfig.Ok then DisplayServerMessageId; return end` // 要按「err != Ok 才中断」读(反混淆把比较符翻了),否则下一步永远不发。 // AskStartPartyDance 的 Callback 什么都不做 ⇒ 客户端在等 SyncPartyDanceStart。 // --------------------------------------------------------------------------------------- private const string PartyDanceSongKey = "PartyDance.SelectedMusicId"; private const string PartyDanceModeKey = "PartyDance.SelectedMode"; [Handler(MethodId.AskSelectPartyDanceSong, HandlerPacketKind.Invoke)] private Task AskSelectPartyDanceSong4229938(Connection conn, UxRpcMessage msg) { var musicId = msg.Body.Length >= 4 ? BinaryPrimitives.ReadUInt32LittleEndian(msg.Body.AsSpan(0, 4)) : 0u; var session = msg.Context?.Session; if (session is not null) session.Items[PartyDanceSongKey] = musicId; conn.Log.Info($"[PARTYDANCE] select song musicId={musicId}"); return conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.AskSelectPartyDanceMode, HandlerPacketKind.Invoke)] private Task AskSelectPartyDanceMode4229938(Connection conn, UxRpcMessage msg) { // 单个 byte(UX.Game.PartyDanceMode,值域 {0,1})。服务端不猜枚举名,回显即可。 var mode = msg.Body.Length > 0 ? msg.Body[0] : (byte)0; var session = msg.Context?.Session; if (session is not null) session.Items[PartyDanceModeKey] = mode; conn.Log.Info($"[PARTYDANCE] select mode={mode}"); return conn.ReturnEmptyOkAsync(msg); } // AskStartPartyDance (63292540) — Invoke ; 无参 ; return: void // // ★ 唯一能让舞会真正开始的推送点。不推 = 面板开得出来、按钮点得动、什么都不会发生。 [Handler(MethodId.AskStartPartyDance, HandlerPacketKind.Invoke)] private async Task AskStartPartyDance4229938(Connection conn, UxRpcMessage msg) { uint musicId = 0; byte mode = 0; var session = msg.Context?.Session; if (session is not null) { if (session.Items.TryGetValue(PartyDanceSongKey, out var songObj) && songObj is uint song) musicId = song; if (session.Items.TryGetValue(PartyDanceModeKey, out var modeObj) && modeObj is byte m) mode = m; } // ZoneGadgetUId 必须 = 0:官方哨兵,语义是「没有舞池机关区域 ⇒ 人人都是参与者」 // (PartyManager.lua:432)。填非 0 还得先把 Slot1/Slot2 占好,否则客户端不认参与者身份。 await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncPartyDanceStart, new SyncPartyDanceStartPush { ZoneGadgetUId = 0, MusicId = musicId, Mode = mode, StartTimestamp = (uint)DateTimeOffset.UtcNow.ToUnixTimeSeconds(), }); conn.Log.Info($"[PARTYDANCE] start musicId={musicId} mode={mode} -> SyncPartyDanceStart"); await conn.ReturnEmptyOkAsync(msg); } // AskLeavePartyDance (63516846) — Invoke ; 无参 ; return: void [Handler(MethodId.AskLeavePartyDance, HandlerPacketKind.Invoke)] private async Task AskLeavePartyDance4229938(Connection conn, UxRpcMessage msg) { // 与 Start 成对,同样用 0 哨兵(PartyWireContracts.cs:356 有说明)。 await conn.NotifyAsync(MethodId.IGameSceneToClient_SyncPartyDanceEnd, new SyncPartyDanceEndPush { ZoneGadgetUId = 0, }); conn.Log.Info("[PARTYDANCE] leave -> SyncPartyDanceEnd"); await conn.ReturnEmptyOkAsync(msg); } // --------------------------------------------------------------------------------------- // 3. PartyLive 派对直播 // // ★★ 数据入口只有一个:S2C 推送 SyncPartyResponse(68456156)。 // PartyLiveStreamPanelStore.lua 自身 grep gClientToGameDelegate = 0 命中, // 一切都挂在 gEventConstants.ON_SYNC_PARTY_RESPONSE 上,而它唯一的发送点是 // PartyManager.lua:702 OnSyncResponse ⇒ 来自 SyncPartyResponse。 // // ⛑⛑ likeList / giftList 的元素不是计数,是观众 npcId // (ProcessGiftList:212-240:`for _, npcId in ipairs(giftList)`,出现一次 = 该观众又送了一份, // 客户端自己 `data.count = data.count + 1` 累加)。写反了就永远显示没人送礼。 // ⛑ npcId 是 u64,客户端过 `gFurnitureUtils:ConvertServerUidToNumber(npcInfo.NpcId)` 后再 // 去 npcIdList 花名册里反查 ⇒ 不在这份花名册里的 id 会被直接丢掉(面板上没反应)。 // // C2S 三条: // SendPartyEvent(63765017) args: int32 eventid, string comment // PartyLiveSwitch(63948406) args: bool start // GmPartyLiveAddEvent(65785804) args: int32 eventid, string message ← GM 通道 // --------------------------------------------------------------------------------------- private const string PartyLiveLedgerKey = "PartyLive.Ledger"; private const int PartyLiveMaxRows = 64; /// 每个 session 一份直播间账本。没有 session 时返回一个丢弃用的空壳。 private static PartyLiveResponsePush PartyLiveLedger(Dictionary? bag) { if (bag is null) return new PartyLiveResponsePush(); if (bag.TryGetValue(PartyLiveLedgerKey, out var existing) && existing is PartyLiveResponsePush ledger) return ledger; var fresh = new PartyLiveResponsePush(); bag[PartyLiveLedgerKey] = fresh; return fresh; } private Task PushPartyLiveAsync(Connection conn, PartyLiveResponsePush response, string why) { conn.Log.Info( $"[PARTYLIVE] {why} -> SyncPartyResponse like={response.LikeList.Count} " + $"gift={response.GiftList.Count} popularity={response.Popularity} msg={response.NPCMessage.Count}"); return conn.NotifyAsync(MethodId.SyncPartyResponse, new SyncPartyResponsePush { Response = response, NPCIds = new List(), }); } [Handler(MethodId.PartyLiveSwitch, HandlerPacketKind.Invoke)] private async Task PartyLiveSwitch4229938(Connection conn, UxRpcMessage msg) { // 单个 bool:`SerializeBase.WritePrimitive(writer, start, writer.WriteBoolean, false)` var start = msg.Body.Length > 0 && msg.Body[0] != 0; conn.Log.Info($"[PARTYLIVE] switch start={start}"); var bag = msg.Context?.Session?.Items; if (start && bag is not null) { // 开播推一次空账本:客户端是纯追加渲染,不会自己清上一次会话残留的礼物提示。 bag[PartyLiveLedgerKey] = new PartyLiveResponsePush(); await PushPartyLiveAsync(conn, new PartyLiveResponsePush(), "switch on (clear)"); } await conn.ReturnEmptyOkAsync(msg); } [Handler(MethodId.SendPartyEvent, HandlerPacketKind.Invoke)] private async Task SendPartyEvent4229938(Connection conn, UxRpcMessage msg) { ReadInt32AndString(msg.Body, out var eventId, out var comment); var bag = msg.Context?.Session?.Items; var ledger = PartyLiveLedger(bag); // PartyLiveStreamPanelStore.lua:发送的是 LTConfig.PartyPartyEventConfig.AskForLike 等事件 id。 // 服务端不猜客户端表里哪个 id 是点赞/送礼(我们手上没有 PartyPartyEventConfig), // ⇒ 按符号分流:正 id 记进 likeList、负 id 取绝对值记进 giftList。 // GM 想指定走礼物就从 GmPartyLiveAddEvent 发负 eventId。 lock (ledger) { if (eventId >= 0) AppendRow(ledger.LikeList, eventId); else AppendRow(ledger.GiftList, -eventId); ledger.Popularity = (uint)(ledger.LikeList.Count + (ledger.GiftList.Count * 3)); } await PushPartyLiveAsync(conn, ledger, $"event id={eventId} comment={comment}"); await conn.ReturnEmptyOkAsync(msg); } // GmPartyLiveAddEvent (65785804) — Invoke ; args: int32 eventid, string message ; return: void // // ★ GM 通道。官方留它是给自己调试用的;我们拿它做「服务端往直播间塞一条弹幕」。 // 这是三个玩法里最容易现场见效的一处(不用改 GM 面板,GATE Lua / GM delegate 直接发即可)。 [Handler(MethodId.GmPartyLiveAddEvent, HandlerPacketKind.Invoke)] private async Task GmPartyLiveAddEvent4229938(Connection conn, UxRpcMessage msg) { ReadInt32AndString(msg.Body, out var eventId, out var message); var bag = msg.Context?.Session?.Items; var ledger = PartyLiveLedger(bag); lock (ledger) { ledger.NPCMessage.Add(new PartyNpcLiveMessagePush { Id = eventId, Message = message }); if (ledger.NPCMessage.Count > PartyLiveMaxRows) ledger.NPCMessage.RemoveRange(0, ledger.NPCMessage.Count - PartyLiveMaxRows); } await PushPartyLiveAsync(conn, ledger, $"gm event id={eventId} msg={message}"); await conn.ReturnEmptyOkAsync(msg); } /// /// 读 int32 eventid + string。string 的形状是 ReadString: /// 无值写单字节 0x00,有值写 0xFF 后跟 UTF8 裸字节。 /// private static void ReadInt32AndString(byte[] body, out int eventId, out string text) { eventId = 0; text = string.Empty; if (body.Length < 4) return; eventId = BinaryPrimitives.ReadInt32LittleEndian(body.AsSpan(0, 4)); var pos = 4; if (pos >= body.Length) return; if (body[pos] == 0) return; pos += 1; if (pos < body.Length) text = Encoding.UTF8.GetString(body, pos, body.Length - pos); } /// 往「累计送礼表」追加一行。每个元素是一次事件,不去重也不断言 id 范围。 private static void AppendRow(List list, int id) { list.Add(id); if (list.Count > PartyLiveMaxRows) list.RemoveRange(0, list.Count - PartyLiveMaxRows); } private static uint U(List list, int index) => index < list.Count ? list[index] : 0u; }