// Balloon ("打气球") zone handlers for client build 4229938. // // WHY THIS FILE EXISTS // The client's balloon mini-game is complete and self-contained // (lua\LX6\SGUI\StoreDefine\BalloonGameStore.lua + Manager\MiniGame\MiniGameDataManager.lua), // but the server side had NO implementation at all. Both RPCs were being answered by // DefaultReturnCatalog4229938's typed-default shells: // // [RPC4229938] unimplemented EnterBalloonZone [0x0404B74F] -> typed-default complex(...) 51b // [RPC4229938] unimplemented LeaveBalloon [0x03FF7BF6] -> typed-default void 0b // // Live evidence (logs\packets-20260927-152501.log) — the player entered the stall and then // pressed "返回" SIX times over ~70 s, every one of them answered error=0: // // 15:26:28.344 C2S INVOKE EnterBalloonZone [0x0404B74F] invoke=109695828 body=9b // 15:26:28.347 S2C RETURN EnterBalloonZone [0x0404B74F] invoke=109695828 error=0 body=51b // 15:26:32.194 C2S INVOKE LeaveBalloon [0x03FF7BF6] invoke=109695831 body=8b // 15:26:32.197 S2C RETURN LeaveBalloon [0x03FF7BF6] invoke=109695831 error=0 body=0b // ... x6 ... // // WHY error=0 STILL MEANT "退不出来" // BalloonGameStore.OnClickBackBtn (lua, decompiled): // if gMiniGameDataManager.curBalloonGadgetId == 0 then // gClientToGameSceneDelegate:LeaveBalloon(gMiniGameDataManager.curBalloonGadgetId) // .Callback = function (err, data) // if err == LTConfig.MessageConfig.Ok then return end -- ★ Ok -> do nothing // L50.L50App.Scene.ZoneGamePlayManager:EndContext() // end // end // The decompiled branch direction is suspect (JMPF/JMPT), but the load-bearing fact is that // replying Ok to LeaveBalloon does NOT close the panel either way. // // THE PATH THAT DOES CLOSE THE PANEL —— ★ R88m CORRECTION, READ THIS FIRST // // R88l claimed the close path was SyncGameGroundZoneInfo + StopBalloon. That was WRONG on the // channel, right on the symptom. The zone gameplay context (which is what the balloon panel // hangs off) is owned by C#, not by Lua: // // C# L50.Gameplay.ZoneGamePlay.ZoneGamePlayManager // StartContext / EndContext / SyncGameGroundZoneState / SyncBalloonGameScore // C# L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext // OnStarted / OnGameEnd / OnEnded / SyncBalloonGameScore / OnSceneItemBreak // (Assets\ScriptNew\L50Game\GamePlay\ZoneGamePlay\BalloonGamePlayContext.cs; // the Lua-visible arm is the ToLua wrapper // L50_Gameplay_ZoneGamePlay_ZoneGamePlayManagerWrap — which is why a Lua-only search // for balloon callers finds exactly one hit: BalloonGameStore.OnClickBackBtn's EndContext().) // // So the server must own TWO channels, not one: // // 1) EnterBalloonZone (67417935, Invoke): return a REAL BalloonZoneInfo object. // ★ Unlike EnterBowlingZone — whose return is a bare u64 — this RPC's return reader is // midToReturnMessageReader[67417935] = Base.ReadComplex(reader, Auto.Reader[680]). // Returning the 51-byte all-zero shell left ZoneSessionId empty, so no session ever // formed and nothing downstream was reachable. // 2) Push SyncGameGroundZoneInfo (68004609, NOTIFY) with the same BalloonZoneInfo (tag 3). // NOTE: this one is consumed by an OLDER Lua dispatcher whose branch table has NO balloon // arm at all (Dart/Bowling/Racing/BBQ/ChineseChess/RingToss/Scratch). Kept for fidelity; // it is NOT what opens or closes anything for balloon. // 3) ★★★ Push SyncGameGroundZoneState (68383444, NOTIFY) — "started" on enter, // GameOver + Dispose on leave. THIS is the channel ZoneGamePlayManager listens on. // Omitting it means the zone context is never entered, so EndContext() has nothing to // tear down and the panel cannot close — i.e. exactly the "退不出来" the player reported. // // SCORE PUSH: SyncBalloonGameScore (64893406) payload is a BARE Int32 // (midToReader[64893406] = function(reader) local score = reader.ReadInt32(reader) return score end). // We push 0 at zone start and again at leave, which is what makes OnGameEnd rank "endType = 0" // (score below Balloon_NormalEndCredit). The client computes the actual hits locally. // // SERIALIZATION: see BalloonWireContracts.cs — the byte layouts come from the client's own Lua // serializer/reader (RPCSerializeAuto.Auto.WriteBalloonZoneInfo / RPCDeserializeAuto.Auto.Reader[680]), // NOT from Drmk.Proxy/proxy/rpc_schema.json (stale older build). using System.Collections.Concurrent; using Ananta.SDK.Network; using Ananta.SDK.Rpc; using Ananta.SDK.Serialization; using Ananta.Server.ClientData.Client4229938; // R110: MiniGameStallCatalog4229938(官方摊位位姿) using Ananta.Server.Configuration; using Ananta.Server.Protocol.Client4229938; using Ananta.Server.RpcTypes.Client4229938; using Auto = Ananta.Server.RpcTypes.Client4229938.Auto; using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene; namespace Ananta.Server.Handlers.Game; internal sealed partial class GameRouter { /// Per-connection balloon session, parked in Session.Items like the bowling one. private const string BalloonStateKey = "client4229938-balloon"; // 局内 session 后缀(只进日志与 ZoneSessionId 字符串,不是实体 id,但同样落盘保持单调)。 private const string BalloonZoneSeqCounter = "Balloon.ZoneSeq"; /// /// ★★★ R89 — 进气球区前的手持武器实例 ID,退出时还原(key = spirit unitId)。 /// 用 TryAdd:重复进入同一局不会把「原来的枪」覆盖成玩具枪。 /// private static readonly ConcurrentDictionary BalloonSavedWeapon = new(); /// /// ★★★ R89 — 当前一轮气球靶:key = spirit unitId,value =(气球实例 ID → 气球 cfgId)。 /// 只在 EnterBalloonZone 建立、LeaveBalloon 清空,用来判定「打爆的是不是本轮的气球」。 /// private static readonly ConcurrentDictionary> BalloonTargets = new(); /// /// ★ R141(2026-09-28)—— side-channel 落盘路径的「报一次」标志。 /// /// WHY:side-channel(把本局气球的真值落盘给客户端注入器读)原先写死 /// D:\Ananta\gym_tools\_balloon_push.json ⇒ **整包发给别人 / 换盘符 / 换个目录就断**: /// 服务端在写 A、注入器在读 B ⇒ pushN 恒 0 ⇒ 气球一个都不出现。 /// 修法见 PushBalloonWallAsync:改成写「与安装位置无关」的共享目录 /// %LOCALAPPDATA%\AnantaBalloon\balloon_push.json, /// 注入器 gym_tools\AnantaBalloonLoader.exe 按**同一规则**算同一路径。 /// 这个标志只用来把「到底用了哪个路径」打一次日志(判活用),不影响主流程。 /// private static bool _balloonPushPathLogged; private sealed class BalloonSession { internal ulong GadgetUId; internal string ZoneSessionId = ""; internal long StartedUtcTicks; internal int Score; internal bool Active; /// The REAL scene-item instance ids pushed at zone entry (R88o). internal readonly List BalloonIds = new(); /// /// ★★★ R112 — 本轮每颗气球**实际下发**的 cfg(id → cfgId)。 /// 用于「按颜色算分」(BalloonWire.CreditForCfg)。旧代码的账本用的是 /// BalloonWire.BalloonSceneItemCfgIds 这个**固定 5 元组**,cfgSweep 生效时与实际不符。 /// internal readonly Dictionary BalloonCfgById = new(); /// /// ★★★ R123 — 上一局**没打掉**的那几颗(局末、撤保护之前抓下来)。 /// /// 为什么必须存它:局末 FinishBalloonRoundAsync 会 /// ① 把本局 16 个 id 全摘出 SceneItemGrid.Protected、 /// ② .Clear()。⇒ 下一局 spawn 时**两条线索都没了**, /// 服务端根本不知道上一局剩了哪几颗,而客户端 hold 里那几颗**还活着** /// (整格重置的心跳只停止"保活",不会主动删客户端的 hold)。 /// 新球又落在**同一批 balloonWallSpots** 上 ⇒ 子弹先命中残留球的碰撞体、 /// 报的是旧 id ⇒ 服务端 ignore break,新球既打不掉也不加分。 /// /// 实测铁证(R122,2026-09-28 08:19~08:20): /// R1 spawn id 0x11..0x20 只打爆 12 颗,未打掉 = {0x15,0x17,0x1C,0x20}; /// R2(0x21..0x30)在 08:20:19 收到 /// [BALLOON] ignore break n=1 first=BA1100000017 —— 0x17 **正是** R1 的残留之一。 /// /// 用法:下一局 spawn 时把这批 id 填进 AOI 报文的 /// aoiInfo.RemoveIds(DestructibleGridAOIIncrease.removeIds, /// 与 addUniqueIds 成对;通道说明见 GameRouter.GameGround.cs:655), /// 客户端据此删掉对应 hold;下发成功后清空。 /// internal readonly List RetiredIds = new(); /// /// ★★★★★ R126(2026-09-28)—— **局代**:每开一局 +1。 /// /// 为什么必须有它(用户实测:「第二轮结算时间非常快,没法正常测试」): /// 本类**按 session 复用、不每局新建**,而 每局都 /// Task.Run 起一个 Task.Delay(40s),醒来后只用 session.Active 当守卫。 /// 玩家在 40 s 内手动 LeaveBalloon 再马上重进时:上一局的任务醒来时 /// **看到的 Active 已经是下一局的 true** ⇒ 直接把新局结算掉。 /// /// 实测铁证(R126,2026-09-28 09:01:29~09:03:15,5 局)—— /// 每局的结束时刻**恰好等于上一局开始时刻 + 40 s**: /// /// R2 结束 09:02:09 = R1 开始 09:01:29 + 40 (R2 只跑了 22 s) /// R3 结束 09:02:27 = R2 开始 09:01:47 + 40 (R3 只跑了 9 s) /// R4 结束 09:02:58 = R3 开始 09:02:18 + 40 /// R5 结束 09:03:15 = R4 开始 09:02:34 + 41 /// /// ⇒ 越往后局越短(玩家 9~25 s 就退再进),完全对得上。 /// /// 用法:SpawnBalloonsAsync 开头 ++,把当次的值闭包给定时任务; /// 任务醒来与 都校验「发起者是不是当前局」。 /// internal int RoundGeneration; } private BalloonSession GetBalloonSession(RpcContext ctx) { if (ctx.Session.Items.TryGetValue(BalloonStateKey, out var raw) && raw is BalloonSession existing) return existing; var created = new BalloonSession(); ctx.Session.Items[BalloonStateKey] = created; return created; } // --------------------------------------------------------------------------------------- // EnterBalloonZone (67417935) — the gameplay door. // // Arg layout is ClientToGameSceneDelegate.lua (SerializerHelper.EnterBalloonZone_Serializer): // gadgetuid : u64 (WriteUInt64) // gametype : byte (CheckEnum(gametype, 66, 0), i.e. an enum with 66 members) // -> 9 bytes. Confirmed live: "C2S INVOKE EnterBalloonZone [0x0404B74F] body=9b" // with payload 01 18 20 E4 A3 08 00 00 00 : // u64 LE = 0x08A3E4201801 (the stall gadget's instanceId) // byte = 0x00 (gametype 0) // // The return is a COMPLEX BalloonZoneInfo (see the file header) — this is deliberately // different from EnterBowlingZone's bare u64. // --------------------------------------------------------------------------------------- [Handler(MethodId.EnterBalloonZone, HandlerPacketKind.Invoke)] private async Task EnterBalloonZone4229938(Connection conn, UxRpcMessage msg) { ulong gadgetUId = 0; byte gameType = 0; if (msg.Body is { Length: >= 9 }) { gadgetUId = BitConverter.ToUInt64(msg.Body, 0); gameType = msg.Body[8]; } // ★★★ R89 — 先发枪,再推 zone。 // 客户端拿到 BalloonZoneInfo 会**立刻**开面板并读 currentWeapon // (BalloonGameStore.lua:341 → gPlayerManager.infoSpirit.bindData.currentWeapon); // 为 nil 就抛 `attempt to index local 'weapon'` ⇒ 面板起不来、气枪模型不出。 // 所以这一步必须在 StartBalloonZoneAsync(内含 zone-info 推送)之前完成。 if (PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonGrantWeapon) await GrantBalloonWeaponAsync(conn, msg.Context); var zoneInfo = await StartBalloonZoneAsync(conn, msg.Context, gadgetUId, gameType, "EnterBalloonZone"); // ★ Return the REAL object. The client reads Dispatch[680] straight out of the reply. await conn.ReturnAsync(msg, zoneInfo); } /// /// ★★★ R89 — 按官方武器发布管线的形状,把气枪(80300134)发布为当前手持武器。 /// 与 GameRouter.Profiles.PublishWeaponSnapshot 同一条链路: /// SyncSpiritWeaponDetail(槽位视图)→ SyncSpiritSwitchWeaponAction(选中实例)。 /// /// ⛔ 不能用 CombatCodec.SpiritWeaponSnapshot —— 它会把 currentWeaponInstanceId /// **强行塞进槽位列表**,而玩具枪不在玩家的常规配装上,必须手搓 SpiritWeaponDetail。 /// /// ⛔ 也**不要**补 SyncAgentWeaponWheel(68121167):对照包 2026-09-26 实测客户端解不开 /// (Player.log: deserialize failed mode=3 mid=68121167 len=24),且它只写 /// agentCurrentWeaponDict,仅 RobotDog/RobotFlyer/RobotSpider 三个机器人控件读它 —— /// 气球面板要的 currentWeapon 来自 SyncSpiritWeaponDetail。 /// private async Task GrantBalloonWeaponAsync(Connection conn, RpcContext ctx) { var state = GetWorldState(ctx); var unitId = state.ActiveSpiritUnitId; if (unitId == 0) return; // 只在首次进入时记录原武器;重复进入不会被玩具枪覆盖记录。 BalloonSavedWeapon.TryAdd(unitId, state.ActiveWeaponInstanceId); var instanceId = BalloonWire.BalloonWeaponInstanceBase | (unitId & 0xFFFF_FFFFUL); var weapon = new SceneMethods.WeaponDetail { SourceAgentSpoonId = 0, SourceAgentId = 0, SourceSceneItemId = 0, IsLocked = false, SourceSceneItemTaskUId = 0, TemplateId = BalloonWire.BalloonWeaponTemplateId, // 80300134 Durability = -1, // 与 cfg.Durability = -1 一致(无限) InstanceId = instanceId, EventId = 0, ReceivedTimeStamp = 0d, OperatorFlags = 0, SpecialLabel = null, SceneItemHp = 1f, StackCount = 1, FightStyleId = 0, // 玩具枪不参与战斗流派 MagazineAmmo = PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonWeaponAmmo, BulletDatas = new Auto.WeaponBulletDatas { BulletId = 0 }, BindPid = PrivateServerConfigStore.Current.Player.Pid, IsPlayerLocked = false, }; var snapshot = new SceneMethods.SpiritWeaponDetail { SpiritTid = state.ActiveSpiritTemplateId, SpiritUid = unitId, CurrentWeaponUid = instanceId, WeaponSlots = new List { weapon }, CurrentTempWeapon = null, TempWeaponSlots = null, VirtualWeaponSlots = new Dictionary(), }; await conn.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot); await conn.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, CombatCodec.SpiritSwitchWeapon(unitId, instanceId)); conn.Log.Info( $"[BALLOON] weapon granted unit={unitId} template={BalloonWire.BalloonWeaponTemplateId} " + $"instance={instanceId} ammo={PrivateServerConfigStore.Current.World.Content.MiniGameStalls.BalloonWeaponAmmo}"); } /// /// ★★★ R89 — 退出气球区时还原进气前的手持武器。 /// 这里**可以**用官方快照(原武器本来就在配装里),避免被玩具枪顶掉。 /// private async Task RestoreBalloonWeaponAsync(Connection conn, RpcContext ctx) { var state = GetWorldState(ctx); var unitId = state.ActiveSpiritUnitId; if (unitId == 0) return; if (!BalloonSavedWeapon.TryRemove(unitId, out var previous) || previous == 0) return; var snapshot = CombatCodec.SpiritWeaponSnapshot(unitId, state.ActiveSpiritTemplateId, previous); await conn.NotifyAsync(MethodId.SyncSpiritWeaponDetail, snapshot); await conn.NotifyAsync(MethodId.SyncSpiritSwitchWeaponAction, CombatCodec.SpiritSwitchWeapon(unitId, previous)); conn.Log.Info($"[BALLOON] weapon restored unit={unitId} instance={previous}"); } /// /// Shared body: mint the per-connection zone session, build the BalloonZoneInfo, push the /// SyncGameGroundZoneInfo the client's manager gates on, and hand a copy back for the Invoke /// reply of EnterBalloonZone. Returns the zone info so both doors (EnterBalloonZone, and a /// future GmEnterBalloonZone) can shape their own reply. /// private async Task StartBalloonZoneAsync( Connection conn, RpcContext ctx, ulong gadgetUId, byte gameType, string source) { var state = GetWorldState(ctx); var session = GetBalloonSession(ctx); session.GadgetUId = gadgetUId; session.ZoneSessionId = $"{BalloonWire.ZoneSessionPrefix}-{ctx.Session.Id}-{IdCounterStore.Next(BalloonZoneSeqCounter, 1)}"; session.StartedUtcTicks = DateTime.UtcNow.Ticks; session.Score = 0; session.Active = true; var playerPos = state.LastReportedPlayerPosition; conn.Log.Info( $"[BALLOON] {source} gadgetUId={gadgetUId} gameType={gameType} " + $"zone={session.ZoneSessionId} " + $"player=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1})"); // ★★ R88n — the session ids and the target cfg. R88o completed this: see below. // Leaving TargetSceneItemCfgId at 0 told the panel "no target" (BalloonGameStore.OnShow // reads data.targetSceneItemId), and an empty BalloonInstanceIdList gave the C# // BalloonGamePlayContext nothing to key its balloonInstanceIdSet on. var zoneInfo = new BalloonZoneInfo { // ★★★ R93 — TargetModelId 不再填 0:客户端 BalloonGameStore.OnShow 读 data.targetId 赋给 // self.targetModelId,0 会让面板拿不到目标气球模型。对照包 Lyco 实测用 BalloonGameSceneItemCfgIds[0] // (当时=80005796 Special),TargetModelId 与 TargetSceneItemCfgId 客户端语义不严格区分,填 cfg id 即可。 // ★ R100:数组已换成「派对气球」,故此处自动变为 80001468;TargetSceneItemCfgId 同步换。 TargetModelId = BalloonWire.BalloonSceneItemCfgIds[0], // R100: 80001468(派对气球 a) TargetSceneItemCfgId = BalloonWire.TargetSceneItemCfgId, // R100: 80001468 Score = 0, BalloonInstanceIdList = new List(), // filled below (real ids) ZoneSessionId = session.ZoneSessionId, // ★ MUST be non-empty GadgetUId = gadgetUId, TaskId = 0, StartReason = BalloonWire.ZoneStartReasonSingle, GameTypeRandom = false, SyncReason = BalloonWire.ZoneSyncReasonEnter, // MUST be Enter(0): the manager branches on it ZoneType = BalloonWire.ZoneTypeBalloon, // ★★★ R93 — ZoneState 用 GameStart(2) 而非 Display(1):对照包 Lyco 实测 GameStart, // 客户端 ZoneGamePlayManager 靠 GameStart 才 StartContext(BalloonGamePlayContext)。 // Display(1) 是 Idle(0) 与 GameStart(2) 之间的过渡态,直接回包给客户端会导致 context 起不来。 ZoneState = BalloonWire.ZoneStateGameStart, ParticipantInfos = new List(), // empty, never null CountDownInfo = new GameGroundZoneCountDownInfo(), }; // ★★★ R88o — ACTUALLY SPAWN THE BALLOONS, then hand their REAL instance ids to the client. // // Why the R88n version (which put *cfg* ids in this list) still showed an empty wall: // the C# consumer keys on instance ids, not cfg ids — // L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext // TargetModelId | TargetSceneItemId | balloonInstanceIdSet | playerScoreDic // | ListenSceneItemBreak | OnSceneItemBreak // and the scene-item hold is only created by the explicit destructible push // (SyncDestructibleGridAOIDecrease.aoiInfo.addInfos), exactly as bowling does. // Doing it here — before the zone-info push — means the ids in the list are ids the client // has already been told about, so the wait never times out. var balloonIds = await SpawnBalloonsAsync(conn, ctx, session, source); zoneInfo.BalloonInstanceIdList = balloonIds; session.BalloonIds.Clear(); session.BalloonIds.AddRange(balloonIds); // ★★★ R89 — 登记本轮靶账本,AskBreakDestructibleObject(s) 靠它判定「打爆的是不是气球」。 // ★★★ R112 修正:账本里的 cfg 必须取 **spawn 实际下发**的那个。 // 旧代码写 `cfgs[i % cfgs.Length]`(BalloonWire 的固定 5 元组),cfgSweep 生效时 // 与实际不符 ⇒ 计分会按错颜色。 var targets = new Dictionary(); foreach (var bid in balloonIds) targets[bid] = session.BalloonCfgById.TryGetValue(bid, out var bidCfg) ? bidCfg : BalloonWire.BalloonSceneItemCfgIds[0]; var spiritUnitId = state.ActiveSpiritUnitId; if (spiritUnitId != 0) BalloonTargets[spiritUnitId] = targets; conn.Log.Info( $"[BALLOON] zone objects targetCfgId={zoneInfo.TargetSceneItemCfgId} " + $"balloons={zoneInfo.BalloonInstanceIdList.Count} targets={targets.Count} " + $"[{string.Join(",", zoneInfo.BalloonInstanceIdList)}]"); await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, zoneInfo); conn.Log.Info($"[BALLOON] pushed SyncGameGroundZoneInfo tag=3 zone={session.ZoneSessionId} score=0 source={source}"); // ★★ SyncGameGroundZoneState (68383444) is the channel ZoneGamePlayManager actually // listens on (C#: L50.Gameplay.ZoneGamePlay.ZoneGamePlayManager.SyncGameGroundZoneState // -> BalloonGamePlayContext.OnStarted). Without it the zone gameplay context is never // "entered", so EndContext() on leave has nothing to tear down and the panel stays. // // ★★★ R93 — 只推 GameStart(2) 一次,不再推 Display(1)。对照包 Lyco 实测只推 GameStart; // 多推一次 Display 会让 ZoneGamePlayManager 先把 context 置为 Display(未开始), // 紧接着 GameStart 又切一次,客户端状态机在两次切换之间可能直接判定结束 → OnEnded // → BalloonGameStore.OnGameEnd 读 resultActiveCtrlEnum(nil) 抛异常 → 面板关不掉("退出不了")。 await PushZoneStateAsync(conn, gadgetUId, BalloonWire.ZoneStateGameStart); conn.Log.Info($"[BALLOON] pushed SyncGameGroundZoneState GameStart zone={session.ZoneSessionId} type={BalloonWire.ZoneTypeBalloon}"); // Push the initial score so the client's panel starts from a known value. The payload is a // bare Int32 — see the file header. await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, 0); conn.Log.Info("[BALLOON] pushed SyncBalloonGameScore score=0"); // Hand the Invoke reply a COPY (the notifier already consumed this instance). return zoneInfo; } /// /// Spawn the breakable balloons as real scene items and return their instance ids. /// /// ★★★ R88o — THIS is the piece that makes "墙上有气球" actually true, and it is the exact /// same three-step dance bowling already does (GameRouter.GameGround.cs:683-930): /// 1) mint real instance ids BalloonInstanceIdBase + ordinal(1..count) /// 2) push them as DestructibleInfo /// SyncDestructibleGridAOIDecrease (68969593) .aoiInfo.addInfos ← creates the hold /// SyncDestructibleObjectAddList (68963429) ← the object add /// with CfgId = the balloon cfg id, PathId = 五色各一 (R89/R100), /// ForceLod0 = false, IScale = 100 (R109, 原 1000 作废), State = Active /// ★★★ R100:CfgId 与 PathId 已**成对**换成资源确实随包的「派对气球」 /// (旧 80005796..80005800 的美术资源不存在 —— 四条证据链见 BalloonWireContracts.cs)。 /// 3) hand those SAME ids back to the caller for BalloonZoneInfo.BalloonInstanceIdList /// /// WHY THIS IS NOT OPTIONAL — the C# consumer keys on INSTANCE ids, not cfg ids: /// L50.Gameplay.ZoneGamePlay.BalloonGamePlayContext holds /// TargetModelId | TargetSceneItemId | balloonInstanceIdSet | playerScoreDic /// and implements OnSceneItemBreak. A break only scores if the broken item's instance id is /// in balloonInstanceIdSet, and nothing can be broken at all unless the item was spawned. /// /// ★★★ R89 LAYOUT — 已改为对照包(Lyco-v0.2.1,真机跑通)的摆法:玩家**前方 6m** 的 4×3 墙。 /// ⛔ 旧的「摊位固定坐标 10×2」不再用。ClientConfig 的 Balloon_RightNum = 10 是**棋盘上限**, /// 不是本局数量;对照包实测本局 = 12(4 列 × 3 行)。 /// /// /// ★★★★★ R122(2026-09-28)—— 把 SceneItemGrid.Protected 里**不属于本局**的气球 id /// 全部摘掉并返回(供 AOI 报文的 aoiInfo.RemoveIds 使用)。 /// /// 为什么必须有这一步:Protected 是 Dictionary<ulong,…>、**只增不减**, /// 而 5 s 心跳会把表里每个 id 都塞回 AddInfos + ExceptIds /// (SceneItemGrid.cs:344-382)⇒ 上一局没打掉的球会被**永远保活**。 /// /// 判定只用高位前缀 (0xBA11), /// 所以不会误伤保龄球(0x9C7B 段)或普通场景物。 /// = 本局刚分配的那 16 个(它们此刻还没进 Protected, /// 传进来只是兜底:任何在本局名单里的 id 一律不删)。 /// private static List PurgeStaleBalloonProtection( TcpSession session, IEnumerable currentRoundIds) { var owner = SceneItemGridStateOf(session); var keep = new HashSet(currentRoundIds); var stale = new List(); lock (owner.SyncRoot) { foreach (var id in owner.Protected.Keys) if (BalloonWire.IsBalloonInstanceId(id) && !keep.Contains(id)) stale.Add(id); foreach (var id in stale) owner.Protected.Remove(id); } stale.Sort(); return stale; } /// /// ★★★ R123(2026-09-28)—— 本局 16 个 id 里,**此刻仍在 Protected 的**(= 本局没打掉的)。 /// /// 为什么在局末抓、而不是下一局抓:FinishBalloonRoundAsync 会紧接着调 /// 把本局 id 全摘出 Protected, /// 摘完就再也查不到"哪几颗没打掉"了(R122 的 PurgeStaleBalloonProtection /// 在下一局 spawn 时才查 ⇒ 表已空 ⇒ 日志 purge stale 恒 0、修了个寂寞)。 /// /// 结果存进 ,由下一局 spawn 作为 /// aoiInfo.RemoveIds 下发。 /// private static List SurvivingBalloonIds(TcpSession session, IEnumerable roundIds) { var owner = SceneItemGridStateOf(session); var alive = new List(); lock (owner.SyncRoot) foreach (var id in roundIds) if (owner.Protected.ContainsKey(id)) alive.Add(id); alive.Sort(); return alive; } private async Task> SpawnBalloonsAsync( Connection conn, RpcContext ctx, BalloonSession session, string source) { var state = GetWorldState(ctx); var anchor = state.LastReportedPlayerPosition; var cfgStall = PrivateServerConfigStore.Current.World.Content.MiniGameStalls; // ★★★ R89 — 改为对照包(Lyco-v0.2.1,真机跑通)的摆放法: // 不再用摊位固定坐标摆一面 10×2 的「棋盘」,而是在**玩家当前位姿的前方**摆 4×3 的墙。 // 理由:私服没有关卡烘焙的摊位摆位(铁律 8「自指锚点」),玩家站位才是权威; // 且 ClientConfig 的 Balloon_RightNum=10 / CameraMinX.. 是**棋盘上限**,不是本局数量。 // Unity 朝向约定:绕 Y 轴 yaw 度 ⇒ forward = (sin,0,cos),right = (cos,0,-sin)。 float yaw = state.LastReportedPlayerRotation.Y; double rad = yaw * Math.PI / 180.0; float fx = (float)Math.Sin(rad), fz = (float)Math.Cos(rad); float rx = (float)Math.Cos(rad), rz = -(float)Math.Sin(rad); var count = cfgStall.BalloonCount; if (count < 1) count = BalloonWire.BalloonCount; // ★★★★★ R111 —— 摆位参数改为**配置驱动**(private-server.json 热重载 ⇒ 改完重新进区即生效, // **不用重启服务端**)。旧版是 const(对照包 Lyco 的值),已全部作废。 var rows = Math.Max(1, cfgStall.BalloonWallRows); // 对照包 BalloonSpawnRows = 3 var cols = Math.Max(1, count / rows); // 12 / 3 = 4 列 var lateralStep = cfgStall.BalloonWallLateralStep; // R111: 1.2 → 0.9 var forward = cfgStall.BalloonWallForward; // player 锚点的前移量(默认 6) var baseHeight = cfgStall.BalloonWallBaseHeight; // R111: 1.2 → 1.25 var heightStep = cfgStall.BalloonWallHeightStep; // R111: 0.7 → 0.5 // ★★★ R112 — 每局重新生成 ⇒ 账本先清空,避免上一局的 cfg 残留。 session.BalloonCfgById.Clear(); // ★★★★★ R126 — **开一局就换一个「局代」**。本局末布防的 40 s 看门狗会把它闭包进去, // 醒来时若发现当前局代已变(= 已经是下一局了)⇒ 自我作废,绝不结算下一局。 // 病因与实测铁证见 BalloonSession.RoundGeneration 的注释。 var roundGen = ++session.RoundGeneration; var cfgs = BalloonWire.BalloonSceneItemCfgIds; // 5 colours var paths = BalloonWire.BalloonSceneItemPathIds; // 5 colours (R89) // ★★★★★ R111 —— 按列换 (cfg, pathId) 组(美术 A/B)。空 = 内置 5 色派对气球。 var cfgSweep = cfgStall.BalloonCfgSweep ?? System.Array.Empty(); var colCfg = new uint[cols]; var colPath = new int[cols]; for (var col = 0; col < cols; col++) { var ok = false; if (cfgSweep.Length > 0) { var spec = cfgSweep[Math.Min(col, cfgSweep.Length - 1)] ?? string.Empty; var pair = spec.Split(':'); if (pair.Length >= 2 && uint.TryParse(pair[0].Trim(), out var c0) && int.TryParse(pair[1].Trim(), out var p0)) { colCfg[col] = c0; colPath[col] = p0; ok = true; } } if (!ok) { colCfg[col] = cfgs[col % cfgs.Length]; colPath[col] = paths[col % paths.Length]; } } if (cfgSweep.Length > 0) conn.Log.Info("[BALLOON] cfg sweep on: " + string.Join(" | ", System.Linq.Enumerable.Range(0, cols).Select(i => $"col{i}=cfg{colCfg[i]}/path{colPath[i]}"))); var infos = new List(count); var ids = new List(count); var cells = new Dictionary<(int, int), bool>(); // ★★★★★ R109 — IScale 默认值 1000 → 100(详见 BalloonWire.DefaultIScale 的更正段)。 // 保龄球(唯一被实测确认看得见的同族)用 100;气球用 1000 且从来没人看见过。 // ⛔ 0 仍是 Gadget 通道的语义,别混(同名不同尺度)。 var iScale = cfgStall.BalloonIScale; // ★★★★★ R109 — 诊断扫描:非空时**按列**给不同 IScale,一次重启同时试 4 个候选值。 // 列号 = ordinal/rows(cols 外层、rows 内层)⇒ 用户只要报告「哪一列看得见」。 var scaleSweep = cfgStall.BalloonIScaleSweep; if (scaleSweep is { Length: > 0 }) conn.Log.Info($"[BALLOON] IScale SWEEP on: col0={scaleSweep[0]} " + $"col1={(scaleSweep.Length > 1 ? scaleSweep[1] : scaleSweep[0])} " + $"col2={(scaleSweep.Length > 2 ? scaleSweep[2] : scaleSweep[0])} " + $"col3={(scaleSweep.Length > 3 ? scaleSweep[3] : scaleSweep[0])} " + $"(rows={rows}, 列序从玩家左手边起)"); // ★★★ R89 — 实例 id:固定 48 位区间 base + 序号(1..count)。 // 绝不用 gadget 散列(低 32 位大时 id 到 49 位,经 Lua double 回传丢精度 ⇒ found=0)。 const int gridCellSize = 40; // 与 WorldContent 的 40m cell 一致 var useLegacyIds = cfgStall.BalloonUseLegacyInstanceIds; // ★★★★★ R118(2026-09-27)—— **实例 id 必须「每局换一段」**。 // // 原来非 legacy 分支是常数 `BalloonInstanceIdBase` ⇒ **每一局都是同样那 16 个 id**。 // 铁证(`logs/console-20260927-231258.log` 一局 16 颗 id 固定 0xBA11…0001–0010)+ // 用户实测两条相互印证的现象: // ① 「金色球打完再下一次重新游戏时不会再刷新出来」——客户端对 uniqueId 有**跨局持久**的 // "已销毁" 记账,第二局收到同一批 id 直接**拒绝重建**(金色正是 `destructible_*` 家族, // 见 R117 §5:它才会被客户端永久记住); // ② 「第二轮打球直接不会掉下去了,钉死在墙上」——非金色那族虽然会被重建, // 但带着上一局的残留状态 ⇒ 站着不动、打不动。 // ⇒ 每局分配新一段 id,客户端每次看到的都是**新物件**,两条现象一起消掉。 // ⚠ 三条硬约束(踩了就会"只生成 1 个"或"查不到"): // 1) 偏移必须是注入侧 `MAX_BATCH = 32` 的**整数倍**,否则 idx=(id-base)%32 会串位; // 2) 全程 ≤48 位(Lua number 只有 53 位尾数 —— 铁律 39); // 3) 高位必须仍以 `ba11` 开头(注入侧 `isBalloon()` 靠前缀认领)。 // ★★★★★ R119 —— 计数器**换成独立的新键**,让**第一局的 id 段等于** // 「唯一被实测证明能计分的那一局」(`0xBA11_0000_0001..0010`)。 // 理由与步长由 128 压到 16 的取证过程见 BalloonInstanceIdRoundStride 的注释。 // ⇒ 第一局 = A/B 的对照组;如果第一局恢复正常,就**确证** id 段是根因。 var batch = useLegacyIds ? (ulong)IdCounterStore.Next(BalloonWire.BalloonSceneItemIdBatchCounter, 0) : (ulong)IdCounterStore.Next(BalloonWire.BalloonInstanceRoundCounter, 0); var batchBase = useLegacyIds ? BalloonWire.BalloonSceneItemIdBase + batch * (ulong)count : BalloonWire.BalloonInstanceIdBase + batch * BalloonWire.BalloonInstanceIdRoundStride; // ★★★★★ R110 —— 锚点:**优先用官方摊位机关自己的位姿**,而不是「玩家前方 6 m」。 // // 病因(R110 三维几何实证,龙栖村气球摊;详见 PrivateServerConfig.BalloonWallAnchor): // 官方摊位 Gadget_DaQiQiu (-4750.50,168.60,-2827.60) yaw=315.0 // 玩家上报位姿 (-4749.00,168.50,-2830.30) yaw=313.3 ⇒ 摊位在正前方 2.97 m // 旧算法玩家前方 6 m ⇒ 墙中心 (-4753.36,-2826.18) = 比摊位再往前 3.19 m // ⇒ 整面墙落在摊位/背景板之后、只从地面 1.2 m 起 ⇒ 被结构遮住 = 什么都看不见。 // ⇒ 服务端载荷 / 注入 / 资源 / hold / transform 全对、物件确实存在,只是几何上被挡住。 // // 扫描:BalloonWallAnchorSweep 按列给「锚点:前移量」,一次重启同时试多个摆位。 var sweep = cfgStall.BalloonWallAnchorSweep ?? System.Array.Empty(); var wantStall = string.Equals(cfgStall.BalloonWallAnchor, "stall", StringComparison.OrdinalIgnoreCase) || Array.Exists(sweep, s => (s ?? string.Empty) .TrimStart().StartsWith("stall", StringComparison.OrdinalIgnoreCase)); MiniGameStallItem4229938? stallHit = null; var stallDist = -1f; if (wantStall) { var match = string.IsNullOrEmpty(cfgStall.BalloonWallStallMatch) ? "DaQiQiu" : cfgStall.BalloonWallStallMatch; var best = float.MaxValue; foreach (var it in MiniGameStallCatalog4229938.Spots.SelectMany(s => s.Items)) { if (it.Name is null || it.Name.IndexOf(match, StringComparison.OrdinalIgnoreCase) < 0) continue; var dx = it.X - anchor.X; var dz = it.Z - anchor.Z; var d2 = dx * dx + dz * dz; if (d2 < best) { best = d2; stallHit = it; } } if (stallHit is not null) stallDist = MathF.Sqrt(best); conn.Log.Info(stallHit is null ? $"[BALLOON] 锚点查找:摊位表里没有名字含 '{match}' 的机关 ⇒ 全部退回玩家锚点" : $"[BALLOON] 锚点查找:命中 {stallHit.Name}({stallHit.PathId}) " + $"@({stallHit.X:F2},{stallHit.Y:F2},{stallHit.Z:F2}) yaw={stallHit.Yaw:F1} " + $"距玩家上报位置 {stallDist:F2}m(告警半径 {cfgStall.BalloonWallStallSearchRadius:F1}m)"); } // 每列的锚点规格:基底(bx,by,bz) + 该锚点的 forward/right + 沿 forward 的前移量 // // ★★★★★ R111 —— **sweep 为空时所有列共用同一个 fwd ⇒ 平墙**。 // 旧代码在 `rawSpec is null` 时写的是 `forward`(player 锚点的 const 6.0), // 却忽略了 ballotWallStallForward ⇒ stall 模式下前移量恒为 0(把墙压在摊位原点上)。 // 现在按 mode 取:stall ⇒ BalloonWallStallForward / player ⇒ BalloonWallForward。 var stallForward = cfgStall.BalloonWallStallForward; var colSpec = new (float bx, float by, float bz, float cfx, float cfz, float crx, float crz, float fwd, string tag)[cols]; for (var col = 0; col < cols; col++) { var rawSpec = sweep.Length > 0 ? (sweep[Math.Min(col, sweep.Length - 1)] ?? string.Empty) : null; var mode = string.Equals(cfgStall.BalloonWallAnchor, "stall", StringComparison.OrdinalIgnoreCase) ? "stall" : "player"; var fwd = mode == "stall" ? stallForward : forward; if (rawSpec is not null) { var parts = rawSpec.Split(':'); mode = parts[0].Trim().ToLowerInvariant(); fwd = (parts.Length > 1 && float.TryParse(parts[1], System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var f2)) ? f2 : 0f; } if (mode == "stall" && stallHit is not null) { var srad = stallHit.Yaw * Math.PI / 180.0; colSpec[col] = (stallHit.X, stallHit.Y, stallHit.Z, (float)Math.Sin(srad), (float)Math.Cos(srad), (float)Math.Cos(srad), -(float)Math.Sin(srad), fwd, "stall"); } else { colSpec[col] = (anchor.X, anchor.Y, anchor.Z, fx, fz, rx, rz, fwd, mode == "stall" ? "player(fallback)" : "player"); } } conn.Log.Info("[BALLOON] 锚点按列: " + string.Join(" | ", colSpec.Select((c, i) => $"col{i}={c.tag}@({c.bx:F2},{c.by:F2},{c.bz:F2}) fwd={c.fwd:F2}m"))); // ★★★★★ R115 —— **官方球位表**(`balloonWallSpots`,逐点世界坐标)。 // // 病因(R115 运行时探针实锤,见 _r114k_out.txt): // 摊位 prefab 里 `QiQiuBan`(气球板)挂着官方那面墙 —— 节点 `BalloonBoard_Square2` // (wp -4752.203,168.6,-2827.108, active=true) 有 **16 个 `BalloonPointXY` 子节点**, // 它们的**世界坐标就是官方球位**(4 列 × 4 行,列距 0.4 m、行距 ≈0.348 m, // 整面墙仅 1.2 m × 1.05 m、离地 0.775–1.82 m)。 // 而 R110/R111 那套「锚点 + forward/lateral/baseHeight/heightStep」是**我们自己编的**, // 算出来是 4.6 m 宽 × 2.6 m 高的散布 ⇒ 看着「不在官方位置」。 // ⇒ 本表非空且全部可解析时,几何算法整段停用,位置一律取本表。 var spots = cfgStall.BalloonWallSpots ?? System.Array.Empty(); var spotXyz = new (float x, float y, float z)[spots.Length]; var spotOk = 0; for (var i = 0; i < spots.Length; i++) { var parts = (spots[i] ?? string.Empty).Split(','); if (parts.Length >= 3 && float.TryParse(parts[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var sx) && float.TryParse(parts[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var sy) && float.TryParse(parts[2].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var sz)) { spotXyz[i] = (sx, sy, sz); spotOk++; } } var useSpots = spots.Length > 0 && spotOk == spots.Length; conn.Log.Info(useSpots ? $"[BALLOON] 官方球位表 on: {spots.Length} 点(BalloonBoard_Square2 烘焙坐标)⇒ 几何算法停用" : $"[BALLOON] 官方球位表 off(配置 {spots.Length} 项 / 可解析 {spotOk} 项)⇒ 退回几何算法"); // ★★★★★ R118(2026-09-27)—— **把官方那 16 个点「拉开间距」**。 // // 用户实测(R117→R118 两轮):「球变成一面墙,都凑在一起」→「球的位置差不多了,但是要分散开」。 // 官方 `BalloonBoard_Square2` 的 4×4 点确实很挤:**列距 0.400 m / 行距 0.348 m, // 整面墙只有 1.2 m × 1.045 m**,而球本身的包围盒是 0.185 × **0.3566** × 0.185 // (`_r109_authoring.json`)⇒ 上下**几乎贴住**,看起来就是"一坨"。 // ⇒ 以质心为轴按倍率放大(1.0 = 官方原样)。热重载键 `balloonWallSpotSpread`。 // ⚠ 只是"拉开",不改中心 —— 用户说"位置差不多了"。 var spotSpread = cfgStall.BalloonWallSpotSpread; if (useSpots && spotSpread > 0f && MathF.Abs(spotSpread - 1f) > 0.001f) { var sscx = 0f; var sscy = 0f; var sscz = 0f; for (var i = 0; i < spotXyz.Length; i++) { sscx += spotXyz[i].x; sscy += spotXyz[i].y; sscz += spotXyz[i].z; } sscx /= spotXyz.Length; sscy /= spotXyz.Length; sscz /= spotXyz.Length; for (var i = 0; i < spotXyz.Length; i++) spotXyz[i] = (sscx + (spotXyz[i].x - sscx) * spotSpread, sscy + (spotXyz[i].y - sscy) * spotSpread, sscz + (spotXyz[i].z - sscz) * spotSpread); conn.Log.Info( $"[BALLOON] 球位间距 ×{spotSpread:F2}(质心=({sscx:F2},{sscy:F2},{sscz:F2}))" + $"⇒ 官方 4×4 的 0.400/0.348 列行距已拉开"); } // ★★★★★ R116(2026-09-27)—— **球位整体朝玩家一侧外移,避开摊位那块「会动的布」**。 // // 用户实测:「气球墙有一块会动的布,这块布经常会把球挡住一点」+「球变成一面墙,都凑在一起」。 // 官方那 16 个点是**贴在板上**的(板正面就是摊位装饰:`p_map01_level_sister_tile_board` // 招牌帘布 / Lianzi 帘子,探针实测它们与球位在同一 y 区间 169.4–170.4)。 // ⇒ 把整组点沿「板法线 ≈ 球位质心 → 玩家」的水平方向外移 balloonWallSpotPull 米, // 球就浮在帘布**前面**,既不穿模也不会被布挡。 // // 方向是量出来的:板局部 +Z ≈ 世界 (0.707, 0, -0.707)(由 BalloonPoint 的局部 X 步进 // (-0.282,-0.282)/0.4 反解出 yaw=135°),而「质心→玩家」实测 ≈ (0.68, 0, -0.73)—— // 两者一致 ⇒ 「朝玩家」就是「沿板面法线往外」。热重载可调,0 = 官方原始位。 var spotPull = cfgStall.BalloonWallSpotPull; if (useSpots && spotPull != 0f) { var spcx = 0f; var spcz = 0f; for (var i = 0; i < spotXyz.Length; i++) { spcx += spotXyz[i].x; spcz += spotXyz[i].z; } spcx /= spotXyz.Length; spcz /= spotXyz.Length; var dxp = anchor.X - spcx; var dzp = anchor.Z - spcz; var dl = MathF.Sqrt(dxp * dxp + dzp * dzp); if (dl > 0.05f) { var ux = dxp / dl; var uz = dzp / dl; for (var i = 0; i < spotXyz.Length; i++) spotXyz[i] = (spotXyz[i].x + ux * spotPull, spotXyz[i].y, spotXyz[i].z + uz * spotPull); conn.Log.Info( $"[BALLOON] 球位外移 {spotPull:F2}m 朝玩家 dir=({ux:F2},{uz:F2}) " + $"质心=({spcx:F2},{spcz:F2}) ⇒ 避开摊位帘布/招牌"); } } // ★★★★★ R127(2026-09-28)—— **每局重排球位**(打散"4×4 方阵"的规律感)。 // // 用户实测:「16 个球全部按规律排好,没有挑战性」「16 个球可以不按规则出现吗」。 // 官方 16 点是 4×4 网格 ⇒ 即使 spotSpread 把它们拉开,排列**仍然有规律**(一眼看穿行/列)。 // 开启 balloonWallRandomize 后,每局在本表点集所定义的**同一片区域**内重新随机撒点: // 区域 = 「主轴(2D PCA) × Y 范围」;深度沿主轴法线**保持不变** // ⇒ 仍是"上下 + 左右铺开、不往前后排"(用户明确要求); // 拒绝采样保证 (主轴位移, Y) 平面内两两距离 ≥ balloonWallMinGap(球包围盒 0.185×0.357×0.185)。 // 颜色仍由 ordinal 决定(col = (ordinal-1)/rows)⇒ 4 色各 4 颗,只是位置被打散。 // ⚠ 新增配置键 ⇒ 必须重编 + 重启服务端(铁律 61)。 if (useSpots && cfgStall.BalloonWallRandomize && spotXyz.Length >= 2) { var rn = spotXyz.Length; var rcx = 0f; var rcz = 0f; var ryMin = float.MaxValue; var ryMax = float.MinValue; for (var i = 0; i < rn; i++) { rcx += spotXyz[i].x; rcz += spotXyz[i].z; if (spotXyz[i].y < ryMin) ryMin = spotXyz[i].y; if (spotXyz[i].y > ryMax) ryMax = spotXyz[i].y; } rcx /= rn; rcz /= rn; var sxx = 0f; var sxz = 0f; var szz = 0f; for (var i = 0; i < rn; i++) { var dx = spotXyz[i].x - rcx; var dz = spotXyz[i].z - rcz; sxx += dx * dx; sxz += dx * dz; szz += dz * dz; } var axisTheta = 0.5f * MathF.Atan2(2f * sxz, sxx - szz); var dirX = MathF.Cos(axisTheta); var dirZ = MathF.Sin(axisTheta); if (sxx + szz < 1e-6f) { dirX = 1f; dirZ = 0f; } var halfW = 0f; for (var i = 0; i < rn; i++) halfW = MathF.Max(halfW, MathF.Abs((spotXyz[i].x - rcx) * dirX + (spotXyz[i].z - rcz) * dirZ)); var gap = cfgStall.BalloonWallMinGap; if (gap <= 0f) gap = 0.42f; var spanY = ryMax - ryMin; // ★★★★★ R140(2026-09-28)—— **帘子接缝禁带**(球不许落在三块帘子之间的木隔断上)。 // // 用户实测(截图):「位置可以了,但是有球会被三块板子中间的隔断挡住」。 // 探针实测:三块帘子 `Lianzi`×3 的 local x = −2.423 / −0.805 / +0.811(间距 1.618 m) // ⇒ 接缝在 local x ≈ −1.614 与 +0.003;而 R139 写进 `balloonWallSpots` 的四个「列」 // 正好是**帘子边界**(local x = −3.232 / −1.615 / +0.002 / +1.619) // ⇒ 第 2、3 列**整列压在接缝上**,随机撒点后同样会落到那里。 // ⇒ 把「接缝两侧 balloonWallForbiddenHalfWidth 米」当禁带,采到就拒绝。 // `balloonWallForbiddenSpans` = 禁带**中心**的世界坐标("x,z"),沿主轴投影成 t 区间。 var forbidT = new System.Collections.Generic.List<(float lo, float hi)>(); var forbSpans = cfgStall.BalloonWallForbiddenSpans ?? System.Array.Empty(); var forbHalf = cfgStall.BalloonWallForbiddenHalfWidth; if (forbHalf <= 0f) forbHalf = 0.27f; for (var i = 0; i < forbSpans.Length; i++) { var fp = (forbSpans[i] ?? string.Empty).Split(','); if (fp.Length >= 2 && float.TryParse(fp[0].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seamX) && float.TryParse(fp[1].Trim(), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out var seamZ)) { var ft = (seamX - rcx) * dirX + (seamZ - rcz) * dirZ; forbidT.Add((ft - forbHalf, ft + forbHalf)); } } if (forbidT.Count > 0) { var sbF = new System.Text.StringBuilder(); for (var i = 0; i < forbidT.Count; i++) sbF.Append(i == 0 ? $"({forbidT[i].lo:F3},{forbidT[i].hi:F3})" : $" ({forbidT[i].lo:F3},{forbidT[i].hi:F3})"); conn.Log.Info( $"[BALLOON] 球位禁带 ×{forbidT.Count}(半宽 {forbHalf:F2}m)t={sbF} " + $"⇒ 避开三块帘子之间的木隔断"); } var rng = new Random(); var placedT = new float[rn]; var placedY = new float[rn]; var made = 0; for (var attempt = 0; attempt < 30000 && made < rn; attempt++) { var t = (float)(rng.NextDouble() * 2.0 - 1.0) * halfW; var yy = ryMin + (float)rng.NextDouble() * spanY; var inForbid = false; for (var k = 0; k < forbidT.Count; k++) { if (t > forbidT[k].lo && t < forbidT[k].hi) { inForbid = true; break; } } if (inForbid) continue; var ok = true; for (var k = 0; k < made; k++) { var dl = t - placedT[k]; var dy = yy - placedY[k]; if (dl * dl + dy * dy < gap * gap) { ok = false; break; } } if (!ok) continue; placedT[made] = t; placedY[made] = yy; made++; } if (made == rn) { for (var i = 0; i < rn; i++) spotXyz[i] = (rcx + dirX * placedT[i], placedY[i], rcz + dirZ * placedT[i]); conn.Log.Info( $"[BALLOON] 球位重排(每局随机) on: {rn} 点 | 轴=({dirX:F2},{dirZ:F2}) " + $"左右半宽 {halfW:F2}m | Y {ryMin:F2}~{ryMax:F2} | 最小间距 {gap:F2}m " + $"| 禁带 {forbidT.Count}"); } else { conn.Log.Info( $"[BALLOON] 球位重排(每局随机) 放弃:只排出 {made}/{rn} 点" + $"(间距 {gap:F2}m 过紧 / 禁带 {forbidT.Count} 段)⇒ 用固定球位表"); } } var ordinal = 0; for (var col = 0; col < cols && ordinal < count; col++) { var cs = colSpec[col]; var lateral = (col - (cols - 1) * 0.5f) * lateralStep; for (var row = 0; row < rows && ordinal < count; row++) { ordinal++; var id = batchBase + (ulong)ordinal; var color = (ordinal - 1) % cfgs.Length; float x, y, z; if (useSpots && ordinal - 1 < spots.Length) { var sp = spotXyz[ordinal - 1]; x = sp.x; y = sp.y; z = sp.z; } else { x = cs.bx + cs.cfx * cs.fwd + cs.crx * lateral; y = cs.by + baseHeight + row * heightStep; z = cs.bz + cs.cfz * cs.fwd + cs.crz * lateral; } var pos = new UXVector3 { X = x, Y = y, Z = z }; // ★★★ R112 — 提取「这一颗实际用的 cfg」:既写进 DestructibleInfo,也记进账本。 // 旧代码账本(BalloonTargets)用的是 BalloonWire.BalloonSceneItemCfgIds 固定数组, // 一旦 cfgSweep 生效就与实际下发的不一致 ⇒ 会按**错误颜色**算分。 var useCfg = cfgSweep.Length > 0 ? colCfg[col] : cfgs[color]; infos.Add(new DestructibleInfo { InstanceId = id, UniqueId = id, CfgId = useCfg, PathId = cfgSweep.Length > 0 ? colPath[col] : (cfgStall.BalloonUseSharedPathId ? BalloonWire.BalloonPathId // R100: 895168017 = 派对气球 a(cfg 80001468) : paths[color]), // R100 五色路径(与 cfgs[] 逐项对应,见 BalloonWireContracts.cs) Position = pos, Facing = new UXVector3(), // ★ R109 — 扫描开启时按**列**取值(col 0..3),否则统一用 iScale。 IScale = (scaleSweep is { Length: > 0 }) ? scaleSweep[Math.Min(col, scaleSweep.Length - 1)] : iScale, NavId = 0, // ★★★ R100 定案 —— **ForceLod0 已被证明不是「看不见」的原因,故回退为 false。** // R100 一度改成 true(理由:GameRouter.Gadget.cs:137 的 gym 道具用 true 且可见)。 // 但同一批代码里 `GameRouter.GameGround.cs:714 / :737`(**保龄球的球和瓶**, // 是唯一被实测证明能渲染出 SceneItem 的兄弟玩法)用的是 **false** —— 它也看得见。 // ⇒ ForceLod0 与可见性无关。真实原因是 R100 的新定案: // 旧 cfg 80005796..80005800 的**美术资源从未随包发布**(见 BalloonWireContracts.cs // 顶部那段四条证据链)。本轮只改「cfg + pathId」这一对变量,其余保持原值。 ForceLod0 = false, BindPid = 0, Hp = 1f, // 一枪一个 State = BalloonWire.DestructibleStateActive, // 0 = Active BreakStage = 0, OccupantInfos = new List(), DropWeaponId = 0, StackCount = 1, EffectIds = new List(), NoSleep = true, ReleaserId = 0, SkillInfo = null, }); ids.Add(id); session.BalloonCfgById[id] = useCfg; // ★★★ R112 按颜色算分的账本 cells[((int)MathF.Floor(x / gridCellSize), (int)MathF.Floor(z / gridCellSize))] = true; } } var px = anchor.X; var pz = anchor.Z; var cx = (int)MathF.Floor(px / gridCellSize); var cz = (int)MathF.Floor(pz / gridCellSize); cells[(cx, cz)] = true; var indexList = new List(); foreach (var key in cells.Keys) indexList.Add(new SceneMethods.GridIndex { X = key.Item1, Z = key.Item2 }); // ★★★★★ R122(2026-09-28)—— **开新一局前,先把上一局没打掉的球删掉。** // // 用户实测原话:「如果我在第一轮没有把气球打完,那么那些气球并不会被销毁, // 所以在第二轮的时候还是会出问题」。 // 实测铁证(console-latest.log,同一会话): // R1 07:56:08 spawn(…0011..0020)→ 只打爆 14 颗 → 剩 …001D/001E // R1 07:56:48 Timeout(40s) 局末 → 撤保护 → 07:56:50 心跳 bytes 回到 1642(整格重置) // R2 07:57:15 / 07:57:22 客户端**仍然**为 …001D/…001E 发破坏报文 // ⇒ `[BALLOON] ignore break n=1 … first=BA110000001D/001E` // ⇒ 「整格重置」**并不会销毁客户端已经建好的 hold**,残留球跨局活着, // 且与 R2 的新球落在**同一个球位**(同一面 16 个 balloonWallSpots) // ⇒ 子弹先命中残留球的碰撞体、报的是旧 id ⇒ 服务端忽略、新球打不掉也不加分。 // // 两条正交的修法(配置项 BalloonRemoveStaleIds 统一开关): // ① 从 `SceneItemGrid.Protected` 摘掉这批历史 id(否则 5 s 心跳一直保活它们); // ② 把它们的 id 填进本次 AOI 报文的 `aoiInfo.RemoveIds` —— 这是 // `DestructibleGridAOIIncrease.removeIds` 的本职(与 `addUniqueIds` 成对; // 通道说明见 GameRouter.GameGround.cs:655),客户端据此删除对应 hold。 // // ★★★★★ R123 更正 —— R122 版在这里改成 `PurgeStaleBalloonProtection`,**结构上不可能命中**: // 局末 `FinishBalloonRoundAsync` 已经 ① 撤保护 ② `BalloonIds.Clear()`, // 所以下一局 spawn 时 `Protected` 里**一个气球 id 都没有** ⇒ 返回空 // ⇒ 日志 `purge stale` 恒 0、`RemoveIds` 恒空。实测(2026-09-28 08:19~08:20,两局) // 正是如此:0 条 `purge stale`、`ignore break` 仍在(first=BA1100000017 ∈ R1 未打掉集合)。 // ⇒ 真正要知道的那批 id 必须**在局末、撤保护之前**抓下来 // (`SurvivingBalloonIds` → `BalloonSession.RetiredIds`)。 // 本条仍保留 `PurgeStaleBalloonProtection` 作兜底(覆盖「中途退场没走局末」的残目)。 var staleIds = new List(); if (cfgStall.BalloonRemoveStaleIds) { staleIds.AddRange(session.RetiredIds); // 上一局正经结算后的残留(主路径) staleIds.AddRange(PurgeStaleBalloonProtection(ctx.Session, ids)); // 兜底:未走局末的残目 staleIds = staleIds.Distinct().OrderBy(x => x).ToList(); } if (staleIds.Count > 0) conn.Log.Info($"[BALLOON] retire stale n={staleIds.Count} " + $"first={staleIds[0]:X} last={staleIds[^1]:X} " + "⇒ 上一局残留:随本次 AOI 报文 RemoveIds 下发删除(客户端应删除对应 hold)"); try { // 1) the hold-creating channel (identical shape to bowling's). var gridArgs = new SyncDestructibleGridAOIDecreaseArgs4229938 { aoiInfo = new DestructibleGridAOIIncrease { PlayerStandardIndex = new SceneMethods.GridIndex { X = cx, Z = cz }, AddInfos = infos, IndexList = indexList, AddUniqueIds = new List(ids), // ★ R123 — 上一局残留下发删除(空 = 上一局打干净了,正常)。 RemoveIds = staleIds, Reason = BowlingWire.AoiReasonInit, }, addGridInfo = new SceneMethods.GridAOIDecrease { SectorIdList = new List { 0 }, BuildingId = 0, StandardIndexList = indexList .Select(g => new SceneMethods.GridIndex { X = g.X, Z = g.Z }).ToList(), ExceptIds = new List(), }, removeGridInfo = new SceneMethods.GridAOIDecrease(), }; var gridBytes = UxSerializer.Serialize(gridArgs); await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes); // ★ R123 — 残留已随上面的报文下发,清账(放在 send 之后:send 抛异常则保留,下次重试)。 session.RetiredIds.Clear(); // 2) the object-add channel (NotifyRaw, NOT Notify: bytes are already a UX body). var addBytes = UxSerializer.Serialize(new DestructibleObjectAddList { AddInfos = infos, Reason = BalloonWire.DestructibleCreateReasonBalloon, // Balloon = 18 }); // ★★★ R98 — SIDE-CHANNEL(照抄 GameRouter.GameGround.cs:877-928 的保龄球版)。 // // WHY: 客户端的场景物层**只有一道门** —— // SceneItemBlob.TryGetSceneItemPlaceInfoDataByPlaceId(uniqueId, out MA_Ref, out blobType) // 它是对「关卡烘焙摆位表」的查表,而官方关卡**没有烘焙任何气球** // (80005796..80005800 的 placeInfo 不在摊位区任何 vfc 块里)。 // ★ R100 补充(这两件事**互相独立**,别混为一谈): // ①「查表查不到」= 客户端不建 hold ⇒ 修法是 R98 这道 side-channel + balloon_fix.js; // ②「美术资源没随包」= hold 建出来了、就是画不出 ⇒ 修法是换 cfg+pathId(R100)。 // R99 扩了 isOurs 后 ① 已解决(实测 hold 12/12、GameObject 非空、位姿正确), // 剩下的现象由 ② 解释 ⇒ **本轮不需要再动注入侧**。 // 本服合成的 0xBA11_xxxx_xxxx id 查不到 ⇒ 返回 false ⇒ 不建 hold ⇒ // 「面板开了、枪拿得起来、就是看不见气球」(R97 定案,R94「不需要注入」已作废)。 // ⇒ 客户端补丁 balloon_fix.js 必须伪造这次查表,而伪造要的就是这里的真值 // (尤其位置:气球墙在玩家前方 4×3,依赖当时的 yaw,客户端自己算会错一帧)。 // // 落盘时机 = Notify **之前**,保证客户端收到 push 时文件已经在盘上。 // 失败绝不影响主流程(整段吞异常)。 try { var inv = System.Globalization.CultureInfo.InvariantCulture; var sb = new System.Text.StringBuilder(); sb.Append("{\"t\":").Append(DateTimeOffset.UtcNow.ToUnixTimeSeconds()) .Append(",\"kind\":\"balloon\",\"count\":").Append(infos.Count) .Append(",\"yaw\":").Append(yaw.ToString("0.###", inv)) .Append(",\"items\":["); for (int i = 0; i < infos.Count; i++) { var it = infos[i]; if (i > 0) sb.Append(','); sb.Append("{\"id\":").Append(it.UniqueId) .Append(",\"cfg\":").Append(it.CfgId) .Append(",\"path\":").Append(it.PathId) .Append(",\"kind\":\"balloon\"") .Append(",\"x\":").Append(it.Position.X.ToString("0.#####", inv)) .Append(",\"y\":").Append(it.Position.Y.ToString("0.#####", inv)) .Append(",\"z\":").Append(it.Position.Z.ToString("0.#####", inv)) .Append(",\"scale\":").Append(it.IScale) .Append(",\"i\":").Append(i) .Append('}'); } sb.Append("],\"player\":{\"x\":").Append(anchor.X.ToString("0.####", inv)) .Append(",\"z\":").Append(anchor.Z.ToString("0.####", inv)) .Append("},\"source\":\"").Append(source).Append("\"}"); var json = sb.ToString(); // ★ R141(2026-09-28)—— 落盘点必须与「服务端装在哪个目录」无关。 // 原先只有下面第 2 条那个写死的 D:\Ananta 路径 ⇒ 整包发给朋友后 // 服务端在写 A、注入器在读 B ⇒ pushN 恒 0 ⇒「气球一个都不出现」。 // 第 1 条是**机器相关但安装位置无关**的共享目录,注入器按同一规则算同一路径 // (gym_tools\bf_loader.py 的 push_path(),两侧必须保持一致,改一处要改两处)。 var sharedPush = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "AnantaBalloon", "balloon_push.json"); foreach (var p in new[] { sharedPush, @"D:\Ananta\gym_tools\_balloon_push.json", Path.Combine(AppContext.BaseDirectory, "config", "balloon_push.json"), }) { try { var dir = Path.GetDirectoryName(p); if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir); File.WriteAllText(p, json); } catch { } } if (!_balloonPushPathLogged) { _balloonPushPathLogged = true; conn.Log.Info($"[BALLOON] side-channel → {sharedPush}(与安装位置无关;注入器读同一路径)"); } } catch { } await conn.NotifyRawAsync(MethodId.SyncDestructibleObjectAddList, addBytes); // ★★★ R93 — 关键补丁:把 12 个气球登记为「受保护的可破坏物」。 // // 病因(v1.9 真机实测,2026-09-27 18:18):气球 18:18:19 生成后, // 场景物网格心跳(heartbeatMs=5000)在 18:18:23 就重发了同一格 (-119,-71) 的 // SyncDestructibleGridAOIDecrease,其 aoiInfo.AddInfos 为空 ⇒ 客户端按自己的 // 烘焙 SceneItemBlob 重建该格 ⇒ 我服合成出来的气球(blob 里没有)被整片抹掉。 // 结果 = 气球打不着、分数恒 0、客户端从不发 AskBreakDestructibleObjects。 // // 登记后,心跳会把它们的 id 放进 addGridInfo.ExceptIds,并把 DestructibleInfo // 重新塞回 aoiInfo.AddInfos ⇒ 气球在整局内稳定存在。 ProtectDestructibles(ctx.Session, infos.Select(i => (i.InstanceId, i.Position.X, i.Position.Z, (object)i))); // ★★★ 2026-09-30 —— 一次性延迟重推(替补被关掉的 5 s 心跳重试通道)。 // // 【背景】sceneItems.heartbeatMs=0(物件闪烁修复,§40)把「心跳把 Protected 的 // 球重新塞回 aoiInfo.addInfos ⇒ 客户端重建 hold 再查一次 placeInfo」这条 // 恢复路径也带走了。而注入器(balloon_fix.js r147 起)的 QUERY hook 是 // 「250 ms 定时器看到新鲜 push 文件才挂」,比客户端收到 AddList 后**当场** // 查表晚 ~0.3–1 s ⇒ 首批 N 个 id 全部 MISS ⇒ 没有重推就永远不显示 // (实测 2026-09-30 15:0x:气球一个都看不见)。 // // 【做法】spawn 后 +1.8 s / +6 s 各重发一次**同一条** grid 报文 // (与 :1079 逐字节相同)。客户端按 AOI 快照语义重建这些 hold ⇒ 第二次 // 查表时 hook 已挂上、PUSH 已读入、TPL 已有(常驻种子挂载,见修复 ⑬) // ⇒ FAKED 成功,气球显示。+6 s 那发是「注入器恰好慢半拍」的保险。 // ⛑ 内容与首发完全相同 ⇒ 幂等(重建同一批 hold),不会抹窗口里其他物件 // (首发同款报文实测不抹)。会话断了就静默停(catch return)。 _ = Task.Run(async () => { try { await Task.Delay(1800); await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes); conn.Log.Info("[BALLOON] 延迟重推 gridAOIDecrease (+1.8s) —— 替补 heartbeatMs=0 后丢失的重试通道"); await Task.Delay(4200); await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes); conn.Log.Info("[BALLOON] 延迟重推 gridAOIDecrease (+6s)"); } catch { } }); var firstPath = cfgStall.BalloonUseSharedPathId ? BalloonWire.BalloonPathId : paths[0]; conn.Log.Info( $"[BALLOON] spawn n={ids.Count} cells={indexList.Count} " + $"gridAOIDecrease(68969593)={gridBytes.Length}b objectAddList(68963429)={addBytes.Length}b " + $"reason=Balloon(18) itemTag=1 anchor=({anchor.X:F1},{anchor.Y:F1},{anchor.Z:F1}) " + $"yaw={yaw:F1} first={ids[0]:X} last={ids[^1]:X} cfg0={cfgs[0]} path0={firstPath} " + $"legacyIds={useLegacyIds} roundBatch={batch} " + $"sharedPathId={cfgStall.BalloonUseSharedPathId} " + $"iScale={iScale} lod0=false source={source} protected={ids.Count}"); } catch (Exception ex) { conn.Log.Warn($"[BALLOON] balloon spawn failed ({source}): {ex.Message}"); } // ★★★★★ R116 — 一局限时(官方 Balloon_CountDown = 40 s),到点服务端主动结算。 // ★ R126:把本局的「局代」一起交给看门狗(见 BalloonSession.RoundGeneration)。 ArmBalloonRoundTimeout(conn, ctx, session, cfgStall.BalloonRoundSeconds, roundGen); return ids; } /// /// ★★★★★ R116(2026-09-27)—— **一局限时看门狗**。 /// /// 用户实测/提问:「打完了不应该是结算游戏吗,现在得自己手动离开」 /// 「这种小游戏肯定会限制只能打几枪吧,不然不限制谁都能打完,没有挑战性」。 /// /// 官方口径(ConfigDump_v3/Static/PoiGameConfig.static.json): /// Balloon_CountDown = 40(一局 40 秒)+ 分值 绿1/蓝3/紫5/金10 /// + 结算线 Balloon_GoodEndCredit = 50 / Balloon_NormalEndCredit = 25。 /// 客户端 BalloonGameStore.OnGameEnd 就是按这两条线排 endType,并在 1.5 s 后 /// 自己 gPanelManager:Close(BALLOON_PANEL)。 /// /// 到点后走与「打光最后一颗」完全相同的结束链路 /// (:final score → GameOver → Dispose)。 /// 客户端提前手动退出时 session.Active 已为 false,这里直接返回(幂等)。 /// /// /// ★★★★★ R126 — 布防那一刻的 。 /// 醒来时若当前局代已变,说明**本局早被玩家手动退出、这是上一局留下的残骸** /// ⇒ 直接丢弃(改前它会把下一局结算掉,用户症状:「第二轮结算时间非常快」)。 /// private void ArmBalloonRoundTimeout( Connection conn, RpcContext ctx, BalloonSession session, int seconds, int roundGen) { if (seconds <= 0) { conn.Log.Info("[BALLOON] 限时关闭(balloonRoundSeconds<=0)⇒ 本局无时限"); return; } conn.Log.Info($"[BALLOON] 限时 {seconds}s 已布防(gen={roundGen},到点自动结算)"); _ = Task.Run(async () => { try { await Task.Delay(TimeSpan.FromSeconds(seconds)); // ★★★★★ R126 — 双守卫:① 本局是否还活跃;② **本局还是"我这局"吗**。 // 旧代码只有 ①,而 session 是跨局复用的对象 ⇒ 上一局的任务醒来时读到的 // Active 是下一局的 true,于是把新局掐掉(实测:每局结束 = 上一局开始 + 40 s)。 if (!session.Active || session.RoundGeneration != roundGen) { conn.Log.Info( $"[BALLOON] 限时任务作废 gen={roundGen} 当前gen={session.RoundGeneration} " + $"active={session.Active} ⇒ 上一局留下的看门狗,丢弃、**不结算**"); return; } conn.Log.Info( $"[BALLOON] 限时到({seconds}s)⇒ 主动结算 score={session.Score} " + $"(GoodEnd>{BalloonWire.BalloonGoodEndCredit} / NormalEnd>{BalloonWire.BalloonNormalEndCredit})"); await FinishBalloonRoundAsync(conn, ctx, session, $"Timeout({seconds}s)", roundGen); } catch (Exception ex) { conn.Log.Warn($"[BALLOON] round timeout failed: {ex.Message}"); } }); } /// /// Push SyncGameGroundZoneState (68383444). Wire = 30 bytes, no markers: /// GameGroundZoneIdentifier (inline struct) GadgetUId u64 | TaskId u32 | ZoneType u8 /// state u8 /// GameGroundZoneCountDownInfo (inline) 4× u32 /// See BalloonWireContracts.cs for the derivation and for why this — not /// SyncGameGroundZoneInfo — is the signal that opens/closes the balloon zone context. /// private static Task PushZoneStateAsync(Connection conn, ulong gadgetUId, byte state) => conn.NotifyAsync(MethodId.SyncGameGroundZoneState, new GameGroundZoneStatePush { ZoneIdentifier = new GameGroundZoneIdentifier { GadgetUId = gadgetUId, TaskId = 0, ZoneType = BalloonWire.ZoneTypeBalloon, }, State = state, CountDownInfo = new GameGroundZoneCountDownInfo(), }); // --------------------------------------------------------------------------------------- // LeaveBalloon (67075062) — the gameplay exit door. // // ClientToGameSceneDelegate.LeaveBalloon(gadgetuid : u64) -> 8 bytes, void return // ("C2S INVOKE LeaveBalloon [0x03FF7BF6] body=8b"). // // ★ Replying Ok is NOT enough to close the panel (see the file header). The close is driven by // MiniGameDataManager.StopBalloon -> store.OnGameEnd -> Timer(1.5s) -> Close(BALLOON_PANEL). // So before we reply, we push the END signal: a final SyncBalloonGameScore plus a // SyncGameGroundZoneInfo carrying ZoneState = GameOver(3). Either of those is what the // client's manager watches for, and pushing both makes the intent unambiguous. // --------------------------------------------------------------------------------------- [Handler(MethodId.LeaveBalloon, HandlerPacketKind.Both)] private async Task LeaveBalloon4229938(Connection conn, UxRpcMessage msg) { ulong gadgetUId = 0; if (msg.Body is { Length: >= 8 }) gadgetUId = BitConverter.ToUInt64(msg.Body, 0); var session = GetBalloonSession(msg.Context); var wasActive = session.Active; // ★★★★★ R115 —— 结束链路抽成 FinishBalloonRoundAsync,供「手动退出」与「清光自动结算」共用。 // 旧代码把整段收尾写在 if (wasActive) 里,自动结算无从复用。 if (wasActive) { await FinishBalloonRoundAsync(conn, msg.Context, session, "LeaveBalloon"); } else if (msg.Context is not null) { // ★★★ R89 — 客户端可能在 zone 还没建立时就点了退出,那把枪同样要收回去。 await RestoreBalloonWeaponAsync(conn, msg.Context); } conn.Log.Info($"[BALLOON] LeaveBalloon gadgetUId={gadgetUId} wasActive={wasActive} -> Ok"); await conn.ReturnEmptyOkAsync(msg); } /// /// ★★★★★ R115 —— **结束一局**(唯一实现;LeaveBalloon 与「清光自动结算」都走这里)。 /// /// 为什么要抽出来:「打完了不结算、得自己手动离开」(R115 用户实测)—— /// 旧实现把结束链路绑死在 LeaveBalloon 上,而客户端**不会**在打光最后一颗球时 /// 自己发这个包(packets 实测:12/12 打完后 5 秒内 C2S 无任何包,直到玩家点退出) /// ⇒ 面板永远不关。现在 ScoreBalloonBreaksAsync 在 targets.Count == 0 /// 时直接调本方法。 /// /// 链路(三者缺一不可,见本文件头的 contract): /// ① final SyncBalloonGameScore(客户端按总分排 endType); /// ② SyncGameGroundZoneInfo + ZoneState = GameOver(3)(带**与开局完全相同**的 /// balloon id 列表 —— 客户端拿它对账,空列表会被忽略); /// ③ SyncGameGroundZoneState = Dispose(4) —— ★ 唯一能真正撕掉 zone context 的通道; /// 只推 ② 关不掉面板;推 GameOver 会走客户端有 bug 的 OnGameEnd(R93 定案)。 /// /// /// ★★★★★ R126 — 发起者的「局代」(); /// -1 = 不校验(当前局自身的流程:手动 LeaveBalloon、清光自动结算)。 /// 只有 40 s 看门狗会传具体值 —— 它是唯一的**异步延迟**发起者,也是唯一可能 /// 「跨局醒来」的调用点。 /// private async Task FinishBalloonRoundAsync( Connection conn, RpcContext? ctx, BalloonSession session, string reason, int expectedGen = -1) { if (!session.Active) return; // ★★★★★ R126 — 只允许「本局自己的发起者」结算本局。 if (expectedGen >= 0 && expectedGen != session.RoundGeneration) { conn.Log.Info( $"[BALLOON] 结算请求作废 发起gen={expectedGen} 当前gen={session.RoundGeneration} " + $"reason={reason} ⇒ 绝不让上一局的看门狗掐掉本局"); return; } // 0) 收枪(避免玩家揣着玩具枪离开摊位)。 if (ctx is not null) await RestoreBalloonWeaponAsync(conn, ctx); // 1) final score await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, session.Score); // 2) zone over: ZoneState = GameOver(3)。id 列表必须与开局一致。 var endBalloonIds = new List(session.BalloonIds); var endInfo = new BalloonZoneInfo { TargetModelId = BalloonWire.TargetModelIdNone, TargetSceneItemCfgId = BalloonWire.TargetSceneItemCfgId, Score = session.Score, BalloonInstanceIdList = endBalloonIds, ZoneSessionId = session.ZoneSessionId, GadgetUId = session.GadgetUId, TaskId = 0, StartReason = BalloonWire.ZoneStartReasonSingle, GameTypeRandom = false, SyncReason = BalloonWire.ZoneSyncReasonEnter, ZoneType = BalloonWire.ZoneTypeBalloon, ZoneState = BalloonWire.ZoneStateGameOver, ParticipantInfos = new List(), CountDownInfo = new GameGroundZoneCountDownInfo(), }; await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, endInfo); // 3) ★ 决定性的一推:Dispose 是终态,直接撕 context(不经过有 bug 的 OnGameEnd)。 await PushZoneStateAsync(conn, session.GadgetUId, BalloonWire.ZoneStateDispose); conn.Log.Info( $"[BALLOON] {reason} pushed END score={session.Score} zone={session.ZoneSessionId} " + $"state=Dispose type={BalloonWire.ZoneTypeBalloon} balloons={endBalloonIds.Count}"); session.GadgetUId = 0; session.Active = false; session.Score = 0; // ★★★★★ R123 — **先抓残留、再撤保护**(顺序反了这一步就白做)。 // // 撤保护(下一行)会把本局 16 个 id 全摘出 `SceneItemGrid.Protected`, // 之后再也无法区分「打掉的」与「没打掉的」;而客户端 hold 里没打掉的那几颗 // 并不会因为「整格重置」心跳而消失(实测 R122:R1 未打掉 {0x15,0x17,0x1C,0x20}, // R2 仍收到 ignore break first=BA1100000017)。 // ⇒ 此刻仍在 Protected 的就是「没打掉的」,存进 RetiredIds 让下一局 spawn 下发 RemoveIds。 // ⚠ 必须带 `BalloonIds.Count > 0` 守卫:本方法可能被调用两次(Timeout 之后又 Leave), // 第二次 `BalloonIds` 已空,若无守卫会把刚记下的残留**冲成空**。 if (ctx is not null && session.BalloonIds.Count > 0) { session.RetiredIds.Clear(); session.RetiredIds.AddRange(SurvivingBalloonIds(ctx.Session, session.BalloonIds)); if (session.RetiredIds.Count > 0) conn.Log.Info($"[BALLOON] retire record n={session.RetiredIds.Count} " + $"first={session.RetiredIds[0]:X} last={session.RetiredIds[^1]:X} " + "⇒ 本局未打掉的球,已记入 RetiredIds,下一局 spawn 时下发 RemoveIds"); } // ★★★ R93 — 撤下气球保护(本局结束,气球已被客户端清掉,再保护就是幽灵条目)。 if (ctx is not null && session.BalloonIds.Count > 0) UnprotectDestructibles(ctx.Session, session.BalloonIds); session.BalloonIds.Clear(); session.BalloonCfgById.Clear(); // ★★★ R89 — 清账本(用 placements 前不能清,这里已经撤完实体了)。 if (ctx is not null) { var unitId = GetWorldState(ctx).ActiveSpiritUnitId; if (unitId != 0) BalloonTargets.TryRemove(unitId, out _); } } // --------------------------------------------------------------------------------------- // AskBreakDestructibleObject (67463592) — ★★★ R89 计分闭环,R88o 完全缺失的那一环。 // // 不打这个 handler 的后果:客户端打碎气球后上报 → 走到 DefaultUnknownInvoke ⇒ // 一条 Warn + 空应答。客户端 err=0 以为受理,**永远等不到分数推送** ⇒ // 不打结分、面板分数恒 0、也没有爆破碎屑的分数气泡。 // // 线格式(客户端 AskBreakDestructibleObject_Serializer,**权威** = AutoGen): // u64 breaker // Complex brokeninfos: // ★ 先写 1 字节标记 0xFF // u64 InstanceId | u32 Stage | u8 BrokenType | UXVector3 Position | UXVector3 Facing // // ★★★ 2026-09-27 修正(R93,原判定有误,此处为更正后结论): // 旧代码把第 8 字节起直接当 u64 读(BitConverter.ToUInt64(body, 8)),**是错的**。 // 真实第 8 字节是 SerializeBase.WriteComplex 写的**对象标记 0xFF** // (RPCSerializeBase.lua:77-104:WriteComplex 只写 1 字节 mark 再写内联字段, // **不带长度前缀**),真正的 InstanceId 在**偏移 9**。 // 读偏 1 字节 ⇒ 取到 0xFF + InstanceId 前 7 字节 拼成的垃圾值 ⇒ // targets.Remove 永不命中 ⇒ 每次都走 else「ignore break」⇒ // **分数永远 0、气球打了没反应**(真机日志 31 处 score=0 + 零处 broken 即此)。 // // 非气球破坏(普通箱子/罐子)⇒ 与本 handler 注册前行为一致:回空 ACK,不改任何状态。 // --------------------------------------------------------------------------------------- [Handler(MethodId.AskBreakDestructibleObject, HandlerPacketKind.Invoke)] private async Task AskBreakDestructibleObject4229938(Connection conn, UxRpcMessage msg) { var body = msg.Body; if (body is null || body.Length < 17 || msg.Context is null) { await conn.ReturnEmptyOkAsync(msg); return; } var breaker = BitConverter.ToUInt64(body, 0); // 跳过 Complex 对象标记(0xFF;null 时为 0x00)。带 0x00 ⇒ 无 brokeninfos,直接空 ACK。 int off = 8; byte mark = body[off]; if (mark == 0x00) { await conn.ReturnEmptyOkAsync(msg); return; } off++; // 吃掉 0xFF 标记 if (body.Length < off + 8) { await conn.ReturnEmptyOkAsync(msg); return; } var brokenId = BitConverter.ToUInt64(body, off); var state = GetWorldState(msg.Context); var unitId = state.ActiveSpiritUnitId; // ★ 诊断:整包 hex + 解析出的 id —— 首次真机若仍不命中,直接看 off=9 是否就是气球 InstanceId。 conn.Log.Info( $"[BALLOON] break-raw len={body.Length} mark=0x{mark:X2} off={off} breaker={breaker} " + $"id={brokenId} unit={unitId} @9={BitConverter.ToUInt64(body, 9):X} hex={Convert.ToHexString(body)}"); if (unitId != 0 && BalloonTargets.TryGetValue(unitId, out var targets) && targets.TryGetValue(brokenId, out var brokenCfg)) { targets.Remove(brokenId); // ★ R116 — 同复数版:立刻从保护名单摘掉,否则心跳(5s)会把它重建出来(打了又刷)。 UnprotectDestructibles(msg.Context.Session, new[] { brokenId }); var session = GetBalloonSession(msg.Context); // ★★★★★ R112 — 官方「按颜色算分」(PoiGameConfig.static.json): // 绿 1 / 蓝 3 / 紫 5 / 金 10。旧代码固定 +10(BalloonScorePerBalloon)已作废。 var credit = BalloonWire.CreditForCfg(brokenCfg); session.Score += credit; var score = session.Score; conn.Log.Info( $"[BALLOON] broken id={brokenId} cfg={brokenCfg} credit={credit} " + $"breaker={breaker} score={score} left={targets.Count}"); // SyncBalloonGameScore(64893406) 的载荷就是一个 int32 // (客户端 reader:midToReader[64893406] = function(reader) return reader.ReadInt32(reader) end)。 await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, score); } else { // 不是本轮气球:保持与注册前一致的空 ACK。 conn.Log.Info($"[BALLOON] ignore break id={brokenId} breaker={breaker} unit={unitId}"); } await conn.ReturnEmptyOkAsync(msg); } /// /// ★★★★★ R112(2026-09-27)—— **复数版破坏计分:客户端真正发的是这一条。** /// /// 实机取证(logs/packets-latest.log 22:18:29): /// C2S NOTIFY AskBreakDestructibleObjects [0x040BF2FC] body=48b /// DECODED {"breaker":0,"brokenInfos":[{"InstanceId":204582177210376(=0xBA1100000008), /// "Stage":0,"BrokenType":1,…}]} /// ⇒ InstanceId 正是服务端登记、注入伪造的那颗气球,解码正确。 /// /// ⛔ 病因:单数版 `AskBreakDestructibleObject`(67463592) 挂着一整套计分逻辑,但 /// 客户端**从不发那一条**;而复数版过去只打一行日志 ⇒ **打爆气球永远不加分**。 /// (GameRouter.Balloon.cs:721 的老注释说「客户端从不发 AskBreakDestructibleObjects」是**反的**。) /// /// 本方法与单数版共用同一套账本(BalloonTargets)与同一套「按颜色算分」(CreditForCfg)。 /// internal async Task ScoreBalloonBreaksAsync( Connection conn, RpcContext ctx, ulong breaker, IReadOnlyList brokenIds) { if (ctx is null || brokenIds.Count == 0) return 0; var state = GetWorldState(ctx); var unitId = state.ActiveSpiritUnitId; if (unitId == 0 || !BalloonTargets.TryGetValue(unitId, out var targets)) { conn.Log.Info($"[BALLOON] break n={brokenIds.Count} no active balloon round (unit={unitId}) => ignored"); return 0; } var session = GetBalloonSession(ctx); var gained = 0; var hits = 0; // ★★★★★ R116 — 被打爆的 id 收集起来,循环后从「受保护」名单里摘掉(见下方注释)。 List? justBroken = null; foreach (var brokenId in brokenIds) { if (!targets.TryGetValue(brokenId, out var cfg)) continue; targets.Remove(brokenId); (justBroken ??= new List()).Add(brokenId); var credit = BalloonWire.CreditForCfg(cfg); session.Score += credit; gained += credit; hits++; conn.Log.Info( $"[BALLOON] broken id={brokenId} cfg={cfg} credit={credit} breaker={breaker} " + $"score={session.Score} left={targets.Count}"); } // ★★★★★ R116(2026-09-27)—— **止住「打了又刷出来」**。 // // 用户实测:「紫,蓝,绿球在不停的出现,掉落,出现,掉落」「打了还会再刷新」 // 「只有金色球正常,所以永远结束不了」。服务端日志给出了硬证据:同一批 id 被打爆 // 之后又收到 11 次 `ignore break`(同一个 InstanceId 再爆一遍,账本里已经没有它了)。 // // 病因是**我们自己**:spawn 时 16 颗球被 `ProtectDestructibles` 登记进 // SceneItemGrid 的 `Protected`,而网格心跳(heartbeatMs=5000)会把 Protected 里的条目 // 塞回 `addGridInfo.AddInfos` 一起重发(SceneItemGrid.cs:358,R93 加的机制)⇒ // 客户端每 5 秒按服务端重发的 DestructibleInfo **重建**已打爆的球(本地销毁状态被覆盖)。 // 结果:永远打不完 ⇒ 永远不进结算;而且重生的球再挨一枪时服务端只能 ignore。 // // ⇒ 打爆一颗,就立刻把它从 Protected 里摘掉:心跳只会重发「还活着」的球。 // (整局结束时 LeaveBalloon 依旧会做一次全量 Unprotect,重复摘除是幂等的。) if (justBroken is not null) { UnprotectDestructibles(ctx.Session, justBroken); conn.Log.Info( $"[BALLOON] unprotect n={justBroken.Count} ⇒ 心跳(5s)不再重建已打爆的球 " + $"(left={targets.Count})"); } if (hits == 0) { // ★★★★★ R119 —— **兜底摘除**(与计分无关,纯防御)。 // 客户端有可能报一个「id 在我们段内,但服务端账本里已经没有了」的球 // (跨局残留 / 重发的球再挨一枪)。这种情况下如果什么都不做,那颗球会一直留在 // `SceneItemGrid.Protected` 里,被 5 s 心跳反复重发 ⇒ 客户端重建 ⇒ **打完又刷新**。 // ⇒ 只要 id 落在本局段内(高 32 位 = 0xBA11)就摘掉;重复摘除是幂等的。 var segIds = new List(); foreach (var brokenId in brokenIds) if (BalloonWire.IsBalloonInstanceId(brokenId)) segIds.Add(brokenId); if (segIds.Count > 0) { UnprotectDestructibles(ctx.Session, segIds); conn.Log.Info($"[BALLOON] unprotect(seg-fallback) n={segIds.Count} " + $"first={segIds[0]:X} ⇒ 心跳不再重建这些段内 id"); } conn.Log.Info($"[BALLOON] ignore break n={brokenIds.Count} breaker={breaker} unit={unitId} " + $"first={brokenIds[0]:X}"); return 0; } // SyncBalloonGameScore(64893406) 载荷 = 裸 int32(客户端 reader: reader.ReadInt32)。 await conn.NotifyAsync(MethodId.IGameToClient_SyncBalloonGameScore, session.Score); conn.Log.Info($"[BALLOON] pushed SyncBalloonGameScore score={session.Score} " + $"(+{gained} from {hits} balloon(s), breaker={breaker})"); // ★★★★★ R115 — **清光即自动结算**。 // 用户实测(R115):「打完了不应该是结算游戏吗,现在得自己手动离开」。 // packets 铁证:12/12 打完后 5 秒内 C2S 一个包都没有(下一次 C2S 就是玩家点退出的 // LeaveBalloon,23:06:03)⇒ 客户端**不会**自己发结束包 ⇒ 结束链路必须由服务端在 // 最后一颗被判定时主动触发,否则面板永远不关。 if (targets.Count == 0) await FinishBalloonRoundAsync(conn, ctx, session, $"AutoFinish(last broken, hits={hits})"); return gained; } /// /// GmEnterBalloonZone (69363970) — the GM-console door into the same zone entry. /// /// Like its bowling twin (GmEnterBowlingZone 69135013) this is a DIFFERENT RPC from /// EnterBalloonZone, and neither implies the other. Arg layout is /// ClientToGameSceneGMDelegate.lua (SerializerHelper.GmEnterBalloonZone_Serializer): /// gadgetuid : u64 (WriteUInt64) /// gametype : u32 (WriteUInt32) <- u32 here, u8 in the gameplay door /// -> 12 bytes. This one is void-returning, unlike EnterBalloonZone which returns the object. /// /// The in-world GM console (what /api/gm/send drives via SyncDoGmCommand) only ever sends this /// id, so with EnterBalloonZone whitelisted and this one missing, the console command would /// fall through to the typed-default body and the client would do nothing. /// [Handler(MethodId.GmEnterBalloonZone, HandlerPacketKind.Invoke)] private async Task GmEnterBalloonZone4229938(Connection conn, UxRpcMessage msg) { ulong gadgetUId = 0; uint gameTypeRaw = 0; if (msg.Body is { Length: >= 12 }) { gadgetUId = BitConverter.ToUInt64(msg.Body, 0); gameTypeRaw = BitConverter.ToUInt32(msg.Body, 8); } // gameType is a u32 on this wire and a byte everywhere downstream; clamp is safe because // the client's own gametype enum only defines small values. var gameType = gameTypeRaw <= byte.MaxValue ? (byte)gameTypeRaw : (byte)0; conn.Log.Info($"[BALLOON] GmEnterBalloonZone gadgetUId={gadgetUId} gameType={gameTypeRaw} bytes={msg.Body.Length}"); await StartBalloonZoneAsync(conn, msg.Context, gadgetUId, gameType, "GmEnterBalloonZone"); await conn.ReturnEmptyOkAsync(msg); } }