Files

945 lines
56 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Text.RegularExpressions;
using Ananta.SDK.Network;
using Ananta.SDK.Rpc;
using Ananta.SDK.Serialization;
using Ananta.Server.Configuration;
using Ananta.Server.Protocol.Client4229938;
using Ananta.Server.RpcTypes.Client4229938;
using SceneMethods = Ananta.Server.RpcTypes.Client4229938.Methods.GameScene;
namespace Ananta.Server.Handlers.Game;
/// <summary>
/// Bowling ("保龄球") + the GameGround zone family.
///
/// WHY THIS FILE EXISTS
/// The client's mini-game code is complete (lua\LX6\MiniGame\BowlingGame\, 26 files) and the server
/// side is empty. The hard gate is <see cref="MethodId.SyncGameGroundZoneInfo"/> (68004609): the
/// client only builds a bowling session when the server pushes a BowlingZoneInfo. Without it
/// gameInstance.zoneInfo stays nil, no panel appears and nothing else is reachable.
///
/// The byte layouts below are taken from two mutually-confirming authoritative sources — the
/// client's own Lua serializer and the client's IL2CPP metadata — and NOT from
/// Drmk.Proxy/proxy/rpc_schema.json, whose `schemas` table is a stale older build. See
/// <c>.workbuddy/bowling_wire_spec.md</c> for the full derivation and the evidence.
///
/// CLIENT FLOW (BowlingGameManager.lua):
/// player interacts with the alley machine (C# BowlingGameBridge -> CreateAndStartGameCs)
/// -> TriggerClientGameFromGadget_Async(entityInstanceId)
/// -> EnterGame(gadgetUid, BowlingGameType.Single, 0)
/// -> ClientToGameSceneDelegate:EnterBowlingZone(gadgetUid, 0, 0) [67832701, Invoke]
/// server: reply u64, then push SyncGameGroundZoneInfo(tag 5 + BowlingZoneInfo)
/// -> OnSyncGameGroundZoneInfo -> CreateGameFromServerImpl -> _Async
/// -> WaitSceneItemAndGadget_Async:
/// 1) up to 5 s for CheckDisableSceneItems(ballList) && (pinList).
/// NOTE that this helper calls SceneItemMgr:TagAndLoadGameobject(id) itself, so the
/// CLIENT loads the objects — the server only has to make the holds exist.
/// 2) array.concat(self.sceneItemBallIdList, zoneInfo.BowlingBallSceneItemIdList)
/// -> the two id lists MUST NOT be null (an empty list is fine).
/// 3) up to 20 s for SpoonGadgetManager:RegisterWaitLoad({GadgetUId}); on timeout the
/// client aborts the session via LeaveBowling(gadgetUId, true).
/// -> GameType == Single && !isPlayAgain -> RealStartGame()
///
/// The holds in step 1 are produced by pushing the balls/pins as DESTRUCTIBLES through
/// SyncDestructibleGridAOIDecrease (68969593) — destructibles and scene items share one id space
/// (DestructibleHUDCtrl.lua resolves a destructible id via SceneItemMgr:GetSceneItemHold), so this
/// replaces the earlier "client Lua hot-patch creates the balls" plan entirely — no fastpatch is
/// needed for the objects.
///
/// ⚠ Do NOT go back to SyncDestructibleGridAOIIncrease (68934309): that id has no implementation on
/// this client build and is silently dropped. See the long note further down this file.
/// </summary>
internal sealed partial class GameRouter
{
/// <summary>Per-connection bowling session, parked in Session.Items like the world-entry state.</summary>
private const string BowlingStateKey = "client4229938-bowling";
private sealed class BowlingSession
{
internal ulong GadgetUId;
internal string ZoneSessionId = "";
internal long StartedUtcTicks;
internal readonly List<ulong> BallIds = new();
internal readonly List<ulong> PinIds = new();
}
private BowlingSession GetBowlingSession(RpcContext ctx)
{
if (ctx.Session.Items.TryGetValue(BowlingStateKey, out var raw) && raw is BowlingSession existing)
return existing;
var created = new BowlingSession();
ctx.Session.Items[BowlingStateKey] = created;
return created;
}
// ★ 规则 104 适配(DRMK-PS_09_HyNB-v0.3,2026-09-24):源版是
// `private static long _bowlingSceneItemSeq = 910000000000L;` 的进程内自增号段。
//
// ⛑⛑ **这个号段不能改**。客户端 BF1 补丁(AnantaBowlingLoader.exe / agent.js)里写死
// var ID_BASE = 0xd3e03a0c01; // = 910000000001
// var BATCH = 15; // 10 瓶 + 5 球
// idx = (n - ID_BASE) % BATCH; ← 靠这个下标决定这条 id 是球瓶还是球
// 把号段抬到 941e9(本仓 910 段另有 RuntimePayloadFactory.cs:1238 的
// `UniqueId = 910000000000UL + templateId` 背包弹药 uid,确有冲突顾虑)会让
// `(n - ID_BASE)` 变成负数/错位,瓶子会被当成球、球被当成瓶子。
// ⇒ 保留 910000000001 起点,改用 IdCounterStore **按 15 个一批**落盘递增:
// 高水位持久化(规则 104 满足)且每批起点仍严格对齐 ID_BASE 的 15 倍数。
private const string BowlingSceneItemBatchCounter = "Bowling.SceneItemBatch";
private const ulong BowlingSceneItemIdBase = 910000000001UL; // == agent.js ID_BASE 0xd3e03a0c01
private const int BowlingSceneItemBatchSize = 15; // 10 瓶 + 5 球(== agent.js BATCH)
// 局内 session 后缀(只进日志与 ZoneSessionId 字符串,不是实体 id,但同样落盘保持单调)。
private const string BowlingZoneSeqCounter = "Bowling.ZoneSeq";
// ---------------------------------------------------------------------------------------
// ★ Official alley frame — measured live off WorldMap_Release::BowlingSceneNode (2026-09-23).
//
// Why this exists: the first cut of this router laid the lane out RELATIVE TO WHEREVER THE
// PLAYER STOOD (state.LastReportedPlayerPosition). The assumption behind it — "the private
// server keeps no alley geometry of its own, the alley holds only furniture" — is wrong.
// The alley ships a complete gameplay rig, and it is all in the baked scene:
//
// MiniGameMachine_Bowling_<uid>/ItemViewNode/.../BowlingSceneNode ← ⚠ gadget CHILD, not scene-baked
// (old numbers below assumed the
// gadget root of the day; see the
// correction block further down)
// ├─ Bowling_lane_Collider plane 19.02 x 0.2 x 1.49 + L/R side cubes + BlockL/BlockR
// ├─ PivotNode/BallSpawnPoint (2693.29, 0.12, 1590.44)
// ├─ PivotNode/PlayerPoint (2689.30, 0.02, 1590.83)
// ├─ PivotNode/PinPointN <- head pin (2711.42, 0.02, 1590.03)
// ├─ ArrowTips(Clone) 21 lane arrows, z = 1590.55, x 2693.34 … 2699.64
// ├─ VirtualCamera / EndVirtualCamera / CinemachineConfiner
// └─ a node literally named
// "碰撞体必须要是 Destructible 层的,不然不会和保龄球产生碰撞"
// WorldMap_Release::Gameplay_Bowling02 the throw timeline (actors + camera, ~2693.29,1590.44)
// WorldMap_Release::GadgetInstanceRoot/MiniGameMachine_Bowling_<uid>/ItemViewNode/BowlingGameNode(Right)
// the two lane rigs (pin racks land at x≈2711.3)
//
// Lane axis is +x (yaw 90°). Two lanes exist side by side 2 m apart; only the z = 1590.44 one
// (the "Right" lane) has a lane collider in BowlingSceneNode, and the dev note above says the
// ball only collides with a Destructible-layer collider — so lane R is the one to use.
//
// Symptom this fixes: with the old player-relative layout the player stands at the machine in
// the alley doorway, so balls/pins spawned at +2 m / +8 m FORWARD FROM THE DOOR and the whole
// mini-game played out in the doorway instead of on the lane.
//
// These are absolute world coordinates from the official rig; the lane is axis-aligned, so no
// yaw math is applied to the pin/ball layout. To move the game to the LEFT lane, add +2.0 to
// every Z below (and re-check that a collider exists there).
// ---------------------------------------------------------------------------------------
// ---- 2026-09-23 11:40 二次修正:机器本身原先就摆错了 15 m -------------------------------
// The block above still had ONE wrong premise: the (2693.29 / 1590.44) rig is a child of the
// MiniGameMachine_Bowling gadget, so it is only there if the GADGET is there. The gadget was
// being spawned at (2689.57, 1591.53) — which is the alley DOORWAY (it even sits on top of
// sector_high_87_54_sound/SoundGate_1 @ (2689.57, 1.22, 1591.48)). The alley's own 11 painted
// lanes are at z = 1563.99 … 1582.66 (2 m pitch). Hence: proper interaction point showed up at
// the door while the lanes were 15 m away.
//
// Correct gadget root (two independent derivations agree to 5 mm):
// root = (2688.73, 0.02, 1576.845) yaw 271.30 [gym_tools\bowl_machine_spawn.py]
// (a) foul line flush with the painted lane head: painted head x 2692.445 - prefab 3.72
// (b) player's own screenshot stance 2686.92 + prefab StandPosRight offset 1.81
// prefab-local offsets (measured):
// lane centre lines z = root - 1.515 / root + 0.485 (2 m apart, NOT the node z)
// lane surface x = root + 3.72 … root + 22.74 (19.02 long, foul line at +3.72)
// head pin x = root + 21.72, z = root - 1.515
// => the constants below are root + those offsets, i.e. the absolute world coords of the
// gadget that is currently spawned at (2688.73, 0.02, 1576.845).
// If the gadget is ever moved (bowl_machine_spawn.py --lane <z>), add the same delta here:
// Δ = (newRootX - 2688.73, newRootZ - 1576.845)
// 2026-09-23 修正:玩家实际站在 L 道(z≈1577.3, 官方锚点 BowlingGameStart=2689.35/1577.291)。
// 之前用的是 R 道(z=1575.3),导致"球瓶在相邻一条道"——差整整一条道(2m)。
// 现直接对齐官方 L 道锚点:BowlingPinPlacementL=2710.49/1577.346(= 道中心线)。
//
// ★★ 2026-09-23 三次修正(bowl_audit_before.txt 实测,铁证):
// 上面原本写的 "BowlingGameNode(L)=2692.50/1577.755" 是**机器轴心**节点,它横向偏了
// 道中心线 0.41~0.43 m,不能拿来当球的 z 基准。真正的道中心线是
// BowlingPinPlacementL.z = 1577.35 (官方烘焙细板实测 1577.33,差 2 cm)
// 本文件上方第 125 行的配方其实早就写明答案:"lane centre lines = root + 0.485
// (NOT the node z)"。旧值 1577.75 = root + 0.905 恰好就是 node z,代码违反了自己的注释。
// 后果:整排球横移 0.42 m,最外球压到道边条;而瓶一直用 1577.35,所以看起来只有球歪。
// ---------------------------------------------------------------------------------------
// ★★ 2026-09-23 第四次修正 —— 改用客户端 GameObject 的实测真值(frida gobjs,权威)
//
// 旧常量把球瓶放在 z = 1577.35。那**不是任何一条道**:
// 球馆里只有 4 台机器(MiniGameMachine_Bowling_*),每台两条道(BowlingPinPlacementL / R)。
// 4 台机器的 L 道 z = 1566.773 / 1572.119 / 1576.114 / 1581.446。
// 1577.35 落在机器 3 与机器 4 之间的空档里,而玩家实际站在机器 1(z ≈ 1566.6)
// ⇒ 球瓶被推到 10.8 m 之外,什么都看不到。
//
// 实测数据(gobjs "BowlingPinPlacement" / "BowlingGameNode" / "BowlingGameStart" /
// "MiniGameMachine_Bowling",每条 4 个实例 = 4 台机器):
// · machineRoot.z + 0.485 = 该机器 L 道的中心线 z ← 本文件第 125 行的配方本来就是对的
// · 同一台机器 L / R 两个瓶位相差正好 2.00 m
// · x 方向:发球线 BowlingGameStart 2689.11 → 机器轴心 BowlingGameNode 2692.26 →
// 瓶阵 BowlingPinPlacement 2710.25,约 18 m,与标准保龄球道长度吻合
// · 所有锚点 y = 0.02(场地地面)
// ---------------------------------------------------------------------------------------
// ★ Lane / machine numbering — MEASURED, not invented (2026-09-23 late, confirmed by the player).
//
// The alley numbers its eight lanes 1..8 with lane 1 at the LARGEST z and lane 8 at the
// SMALLEST z, i.e. the numbering runs OPPOSITE to ascending z. The player stood at z=1566.77
// and read the painted number "7" on that lane — which is this array's index 0. So:
//
// asc-z order (small→large): 1564.77 1566.77 1570.12 1572.12 1574.11 1576.11 1579.45 1581.45
// game lane number : 8 7 6 5 4 3 2 1
//
// MachineNo/LaneNo below are the IN-GAME labels. Do not "fix" them back to ascending order —
// the geometry is right, only the naming was reversed before, which made every log line read
// one machine off (what the player pointed out as "why does it call this machine 1 when the
// number on the lane is 7, shouldn't it be machine 4 L?" — it should, and now it does).
private readonly record struct BowlingLane(
int MachineNo, int LaneNo,
float MachineX, float MachineZ, float LaneZ, float PinApexX,
float StartX, float StartZ, float GameNodeX, float GameNodeZ);
private static readonly BowlingLane[] BowlingLanes =
[
new(4, 7, 2688.4900f, 1566.2720f, 1566.7732f, 2710.2510f, 2689.1104f, 1566.7180f, 2692.2559f, 1567.1820f),
new(4, 8, 2688.4900f, 1566.2720f, 1564.7737f, 2710.2056f, 2689.1104f, 1566.7180f, 2692.2559f, 1567.1820f),
new(3, 5, 2688.6079f, 1571.6180f, 1572.1193f, 2710.3689f, 2689.2283f, 1572.0641f, 2692.3738f, 1572.5281f),
new(3, 6, 2688.6079f, 1571.6180f, 1570.1198f, 2710.3235f, 2689.2283f, 1572.0641f, 2692.3738f, 1572.5281f),
new(2, 3, 2688.6990f, 1575.6130f, 1576.1143f, 2710.4600f, 2689.3193f, 1576.0591f, 2692.4648f, 1576.5231f),
new(2, 4, 2688.6990f, 1575.6130f, 1574.1147f, 2710.4146f, 2689.3193f, 1576.0591f, 2692.4648f, 1576.5231f),
new(1, 1, 2688.8230f, 1580.9449f, 1581.4462f, 2710.5840f, 2689.4434f, 1581.3910f, 2692.5889f, 1581.8550f),
new(1, 2, 2688.8230f, 1580.9449f, 1579.4467f, 2710.5386f, 2689.4434f, 1581.3910f, 2692.5889f, 1581.8550f),
];
/// <summary>
/// The lane the player is standing on: nearest by z. The 8 entries are the alley's two lanes on
/// each of its four machines; picking by z means the balls and pins land where the player is
/// instead of on a hard-coded lane 10 m away.
/// </summary>
private static BowlingLane BowlingPickLane(float playerZ)
{
var best = BowlingLanes[0];
var bestD = float.MaxValue;
foreach (var lane in BowlingLanes)
{
var d = MathF.Abs(lane.LaneZ - playerZ);
if (d < bestD) { bestD = d; best = lane; }
}
return best;
}
private const float BowlingPinBaseY = 0.02f; // 场地地面高度(实测锚点 y)
private const float BowlingPinDepthStep = 0.26f; // per pin row, away from the player (+x)
private const float BowlingPinLateralStep = 0.30f; // within a row, across the lane (z)
private const float BowlingBallRackBack = 1.0f; // ball rack sits this far behind the foul line
private const float BowlingBallLateralStep = 0.35f;
private const float BowlingLaneHeadY = 0.12f; // 球心高度(球半径 ~0.11 + 地面 0.02)
// ---------------------------------------------------------------------------------------
// ★ WHY THE BALLS AND PINS NEVER APPEARED (root-caused 2026-09-23 LATE — replaces two earlier
// and WRONG theories that used to sit in this comment block)
//
// Symptom: "[BowlingGameManager] scene item 9100000000NN hold not found" for all 15 ids, then
// "pool[101] is nil" x73 and "[BowlingCharacter] AttachBallToHandr: self.currentBallGo is nil"
// — i.e. no ball in the hand AND no pins, which is ONE bug, not two.
//
// ★ TRUE CAUSE: the holds were never created, because the server pushed the wrong method id.
// SyncDestructibleGridAOIIncrease (68934309) does NOT exist on this client build. Proof: frida
// scanned all 69 259 classes across every assembly and the name appears ONLY on
// UX.Game.IGameSceneToClient (the interface) and UX.Game.Client.NullGameSceneToClientImpl (the
// no-op). LX6.Service.GameSceneToClientImpl — the class registered for S2C dispatch — does not
// have it. So the message was dropped with no error line at all, which is why this took so long:
// "no deserialize failure in the client log" was mistaken for "the client accepted it".
//
// The live sibling is SyncDestructibleGridAOIDecrease (68969593), and the ADD travels inside its
// `aoiInfo` parameter. That is the same trap the Gadget channel documents for itself — see
// Methods/GameScene/GadgetGridMethods4229938.cs: "only the *Decrease* side has a method id; the
// add is carried inside GadgetGridAOIIncrease aoiInfo". And the Gadget channel works: it is what
// put the interaction buttons in the right place.
//
// Corroborating measurement: with the alley fully loaded and the player standing in it,
// SceneItemMgr held exactly ONE SceneItemHold in the whole world — the one a frida experiment
// had built by hand via AddSceneItem(16). The client's own scene-item AOI had produced nothing,
// and SceneItemBlob.placeIdToGridSeekId (5434 authored placements) has no bowling entry near
// the alley cell (67,39). So the pins and balls are NOT baked scene items anywhere: the server
// has to hand them over explicitly, through aoiInfo.addInfos.
//
// Two theories this block used to assert, both now disproven:
// ✗ "68934309 DOES create the holds; only the GameObject load fails" — there were no holds.
// ✗ "PathId 1838106492 / 1959229003 are not scene-item pathIds so the prefab cannot resolve"
// — the pairing is right (confirmed in SceneItemPathInfoRuntime, 4325 live entries) and the
// BallCfgId/BallPathId pair is the one frida verified end-to-end. PathId was never the
// problem; it only looked like the sole candidate while the real bug hid in the method id.
//
// These values stay overridable from the hot-reloaded config/bowling_wire.json so a future
// pairing can be dropped in WITHOUT rebuilding or restarting the server.
// ---------------------------------------------------------------------------------------
private static readonly object BowlingWireLock = new();
private static string? _bowlingWireFile;
private static long _bowlingWireStamp = -1;
private static uint _ballCfg = BowlingWire.BallCfgId;
private static int _ballPath = BowlingWire.BallPathId;
private static uint _pinCfg = BowlingWire.PinCfgId;
private static int _pinPath = BowlingWire.PinPathId;
private static int _destructibleTag = BowlingWire.DestructibleTagBase;
// ---------------------------------------------------------------------------------------
// ★★ 球架的 5 个球(2026-09-24:「切球不能在这 5 个球中变换」的修复)
//
// 症状:按「切球」把下标在 1..5 之间切,拿到的球长得一模一样。
// 根因:服务端把 5 个球全按同一对 (ballCfg, ballPath) 推出去 ⇒ 客户端池子里是 5 个同款球。
// 而客户端取球是**纯按下标**:BowlingBallLauncher.CreateAnimBall ->
// gBowlingGameManager:Rent(self.CurBallIndex) -> Rent 内
// self.gameInstance.sceneItemBallList[type]。
// 下标顺序 = zoneInfo.BowlingBallSceneItemIdList 顺序 = 服务端 BallIds 顺序
// (客户端的 RegisterSceneItemBall 在本 build 里只有定义没有调用,列表完全来自服务端)。
// 完整的下标↔重量推导 + 活体 pathId 真表见 BowlingWire.BallSlotCfgIds 的注释。
// 修法:按推送顺序(= 客户端下标 1..5)用 5 组不同的 (cfg, pathId) = 8/10/12/14/16 磅。
//
// ballSet 语义(config/bowling_wire.json 热加载,默认 0):
// 0 = 用 BowlingWire.BallSlotCfgIds/PathIds 的 5 个不同球(正常模式)
// 1 = 退回旧行为:5 个球全部用 ballCfgId/ballPathId 那一对(诊断 / 对照组)
// ballCfgN / ballPathN(N=1..5)可单槽覆盖,便于一次重启里做 A/B,
// 例:{"ballPath1":1838106492} 只把 1 号球换成对照球,其余不动。
// ---------------------------------------------------------------------------------------
private static int _ballSet;
private static uint[] _ballSlotCfg = (uint[])BowlingWire.BallSlotCfgIds.Clone();
private static int[] _ballSlotPath = (int[])BowlingWire.BallSlotPathIds.Clone();
// ---------------------------------------------------------------------------------------
// ★ 临时诊断开关:球瓶 (cfg,path) 交叉替换 + 计数编码实验(2026-09-24 第二轮)
//
// 症状:球瓶有物理、有撞击音效,但看不见。
//
// 已确证(本轮 ConfigDump 离线取证,全部可复现):
// · SceneitemConfig.json 里 cfg 80003145 = "DefaultSceneItem/DefaultSceneItem_bowlingalley_
// bowlingpin001a" ⇒ **球瓶 cfg 是对的**(球 80002967 = ..._bowlingsphere004a)。
// modelId 对照:52007069=pin, 52007070=sphere004a, 52007071=003a, 52007072=002a, 52007073=001a。
// · SoundDestructibleConfig.Id **就是场景物的 pathId**(实测规则,6 个已验 pathId 中 5 个命中,
// 材质名与物体类型全部吻合:1838106492→plastic_bowling_ball、2132486638→plastic_bin、
// 1843390629/680591215→soft_sofa;唯一未命中的 1838106493 是那个改用 ModelResPath 的 16 磅球)。
// ⇒ 1959229003 出现在该表且 FillInMaterial = "wooden_bowling_pin",
// **它很可能真的是球瓶的 pathId**(上一轮"它只是音效材质 id"的判断是错的)。
// · pathId 不是资源名哈希(fnv1a/fnv1/djb2/sdbm/java/crc32/murmur3/xxh32/md5/sha1 × 14 种写法
// 全部落空),也不是小整数索引 ⇒ 只能实测,不能离线推算。
// · 客户端那 4325 条 PathInfo 表里按保龄球 cfg 白名单只命中 5 条、全是球,不含 80003145。
//
// ⇒ 现在剩下两个变量:cfg 与 path 到底哪个决定"有没有模型/渲染哪种模型"。
// 本开关把 10 个球瓶按 4/3/2/1 分成 4 组,同时改 cfg 和 path:
// 组0(4 个)= (80002967, 1838106492) ← 球的**原配对**,纯对照组。
// ★ 这 4 个只要渲染出来,就证明"服务端推的场景物能出模型",本机制成立。
// 组1(3 个)= (80003145, 1838106492) ← 球瓶 cfg + 球的 path ⇒ 检验是不是 cfg 决定模型
// 组2(2 个)= (80003145, 1838106494)
// 组3(1 个)= (80003145, 1838106495)
//
// 读数对照表(看球瓶/球各现几个):
// 4 球 + 3 瓶 ⇒ cfg 决定模型 ⇒ 直接可用 (80003145, 1838106492) 收工
// 4 球 + 3 球 + 2 瓶 ⇒ path 决定模型,且 1838106494 就是球瓶 ⇒ 收工
// 4 球 + 3 球 + 1 瓶 ⇒ path 决定模型,且 1838106495 就是球瓶 ⇒ 收工
// 4 球 + 3 球 + 0 瓶 ⇒ 494/495 都不是 ⇒ 下一轮试 1838106496,以及回测 1959229003
// 只有 4 球、组1 什么都没出 ⇒ cfg/path 必须是**成对匹配**的 ⇒ 只能靠 path 逐个试
// 连 4 球都不出 ⇒ 推过去的东西根本不渲染("球看得见"其实是 BF1 手上那颗,
// 不是我们推的 5 颗)⇒ 要换整体思路,别再猜 pathId 了
//
// 打开方式:config/bowling_wire.json 写 {"pinPathProbe":1}。删掉或写 0 即恢复用 pinPathId。
// ---------------------------------------------------------------------------------------
private static int _pinPathProbe = 0;
private static readonly uint[] PinPathProbeCfgs = { 80002967u, 80003145u, 80003145u, 80003145u };
private static readonly int[] PinPathProbePaths = { 1838106492, 1838106492, 1838106494, 1838106495 };
private static readonly int[] PinPathProbeQuota = { 4, 3, 2, 1 };
/// <summary>把第 ordinal 个球瓶(0 起,按推送顺序)映射到候选下标。见 _pinPathProbe。</summary>
private static int PinPathProbeCandidate(int ordinal)
{
int acc = 0;
for (int i = 0; i < PinPathProbeQuota.Length; i++)
{
acc += PinPathProbeQuota[i];
if (ordinal < acc) return i;
}
return PinPathProbeQuota.Length - 1;
}
/// <summary>
/// Hot-reloaded bowling wire overrides. File: <c>config/bowling_wire.json</c> next to the
/// server (same folder as private-server.json). Shape:
/// <c>{"ballCfgId":80002967,"ballPathId":1838106492,"pinCfgId":80003145,"pinPathId":1959229003,"destructibleTag":1}</c>
/// Missing file / missing key / unparsable value = keep the compiled-in default. Re-read
/// whenever the file's last-write tick changes, so an edit takes effect on the next zone entry.
///
/// <c>destructibleTag</c> (default 1, alternate 255) picks the polymorphic marker byte used for
/// each DestructibleInfo on the wire — see BowlingWire.DestructibleTagBase for why the old
/// hard-coded 0xFF made the client answer "deserialize failed ... parameter=DestructibleInfo"
/// and create no balls or pins at all.
///
/// <c>pinPathProbe</c> (0 = off) turns on the 10-pin (cfg,path) A/B sweep described above
/// <see cref="_pinPathProbe"/>; 0 (or omitting the key) restores the plain pinCfgId/pinPathId pair.
/// </summary>
private static (uint ballCfg, int ballPath, uint pinCfg, int pinPath) BowlingWireNow()
{
try
{
if (_bowlingWireFile == null)
{
var candidates = new List<string>();
Action<string> addDir = (string dir) =>
{
if (string.IsNullOrEmpty(dir)) return;
var d = new DirectoryInfo(dir);
for (int up = 0; up < 6 && d != null; up++, d = d.Parent)
candidates.Add(Path.Combine(d.FullName, "config", "bowling_wire.json"));
};
addDir(AppContext.BaseDirectory);
try { addDir(Directory.GetCurrentDirectory()); } catch { }
_bowlingWireFile = candidates.FirstOrDefault(File.Exists) ?? "";
}
if (_bowlingWireFile.Length == 0) goto done;
var stamp = new FileInfo(_bowlingWireFile).LastWriteTimeUtc.Ticks;
if (stamp == _bowlingWireStamp) goto done;
lock (BowlingWireLock)
{
if (stamp == _bowlingWireStamp) goto done;
var txt = File.ReadAllText(_bowlingWireFile);
_ballCfg = BowlingWire.BallCfgId;
_ballPath = BowlingWire.BallPathId;
_pinCfg = BowlingWire.PinCfgId;
_pinPath = BowlingWire.PinPathId;
_destructibleTag = BowlingWire.DestructibleTagBase;
_pinPathProbe = 0;
_pinAsGadget = 0;
_gadgetProbePathId = 79300636;
_gadgetPushDelayMs = 0;
_ballSet = 0;
_ballSlotCfg = (uint[])BowlingWire.BallSlotCfgIds.Clone();
_ballSlotPath = (int[])BowlingWire.BallSlotPathIds.Clone();
foreach (var kv in ParseFlatJson(txt))
{
switch (kv.Key)
{
case "ballCfgId": _ballCfg = kv.Value.u; break;
case "ballPathId": _ballPath = kv.Value.i; break;
case "ballSet": _ballSet = kv.Value.i; break;
case "ballCfg1": _ballSlotCfg[0] = kv.Value.u; break;
case "ballCfg2": _ballSlotCfg[1] = kv.Value.u; break;
case "ballCfg3": _ballSlotCfg[2] = kv.Value.u; break;
case "ballCfg4": _ballSlotCfg[3] = kv.Value.u; break;
case "ballCfg5": _ballSlotCfg[4] = kv.Value.u; break;
case "ballPath1": _ballSlotPath[0] = kv.Value.i; break;
case "ballPath2": _ballSlotPath[1] = kv.Value.i; break;
case "ballPath3": _ballSlotPath[2] = kv.Value.i; break;
case "ballPath4": _ballSlotPath[3] = kv.Value.i; break;
case "ballPath5": _ballSlotPath[4] = kv.Value.i; break;
case "pinCfgId": _pinCfg = kv.Value.u; break;
case "pinPathId": _pinPath = kv.Value.i; break;
case "destructibleTag": _destructibleTag = kv.Value.i; break;
case "pinPathProbe": _pinPathProbe = kv.Value.i; break;
case "pinAsGadget": _pinAsGadget = kv.Value.i; break;
case "gadgetProbePathId": _gadgetProbePathId = kv.Value.i; break;
case "gadgetPushDelayMs": _gadgetPushDelayMs = kv.Value.i; break;
}
}
_bowlingWireStamp = stamp;
}
}
catch
{
// never let a bad override file break zone entry
}
done:
return (_ballCfg, _ballPath, _pinCfg, _pinPath);
}
/// <summary>Minimal flat {"k":number|"s"} reader — no JSON dependency in this assembly.</summary>
private static IEnumerable<KeyValuePair<string, (uint u, int i)>> ParseFlatJson(string txt)
{
foreach (Match m in Regex.Matches(txt, "\"(?<k>\\w+)\"\\s*:\\s*\"?(?<v>-?\\d+)\"?"))
{
if (!long.TryParse(m.Groups["v"].Value, out var v)) continue;
yield return new KeyValuePair<string, (uint, int)>(
m.Groups["k"].Value, ((uint)(v & 0xFFFFFFFFL), (int)v));
}
}
// ---------------------------------------------------------------------------------------
// EnterBowlingZone (67832701) — args: u64 gadgetuid, byte gametype, u32 agentid
// ---------------------------------------------------------------------------------------
[Handler(MethodId.EnterBowlingZone, HandlerPacketKind.Invoke)]
private async Task EnterBowlingZone4229938(Connection conn, UxRpcMessage msg)
{
// ClientToGameSceneDelegate.EnterBowlingZone(gadgetuid : u64, gametype : byte, agentid : u32)
ulong gadgetUId = 0;
byte gameType = BowlingWire.BowlingGameTypeSingle;
uint agentTemplateId = 0;
if (msg.Body is { Length: >= 13 })
{
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
gameType = msg.Body[8];
agentTemplateId = BitConverter.ToUInt32(msg.Body, 9);
}
await StartBowlingZoneAsync(conn, msg.Context, gadgetUId, gameType, agentTemplateId, "EnterBowlingZone");
// The invoke's return value is the agent id handed to EnterGame's callback; 0 for Single.
await conn.ReturnAsync(msg, 0UL);
}
// ---------------------------------------------------------------------------------------
// GmEnterBowlingZone (69135013) — the GM-console door into the very same zone entry.
//
// Measured against this build, not guessed:
// * ClientToGameSceneGMDelegate.lua:417-425 serialises
// (gadgetuid : u64, gametype : u32, agentid : u32) -> 16 bytes. The live log confirmed
// "-- rpc GmEnterBowlingZone [0x041EEAA5] 16b".
// * RPCDeserializeAuto.lua:16312 gives 69135013 an EMPTY return reader, i.e. this one is void
// (unlike EnterBowlingZone at :15955, which reads back a u64 agent id).
//
// These are two DIFFERENT RPCs and neither implies the other. The in-world GM console — which is
// exactly what the debug API's /api/gm/send drives via SyncDoGmCommand — only ever sends
// 69135013. So with EnterBowlingZone whitelisted and this one missing, the command fell through
// to DefaultUnknownInvoke4229938's typed-default body: the client got a structurally valid empty
// reply and simply did nothing, /api/gm/send reported "尚未回显", and the server log showed
// "! [RPC4229938] unimplemented GmEnterBowlingZone [0x041EEAA5] -> typed-default void 0b".
// The gameplay path (walking up to the alley machine) uses 67832701 and was never affected.
// ---------------------------------------------------------------------------------------
[Handler(MethodId.GmEnterBowlingZone, HandlerPacketKind.Invoke)]
private async Task GmEnterBowlingZone4229938(Connection conn, UxRpcMessage msg)
{
ulong gadgetUId = 0;
uint gameType = BowlingWire.BowlingGameTypeSingle;
uint agentId = 0;
if (msg.Body is { Length: >= 16 })
{
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
gameType = BitConverter.ToUInt32(msg.Body, 8);
agentId = BitConverter.ToUInt32(msg.Body, 12);
}
conn.Log.Info($"[BOWLING] GmEnterBowlingZone gadgetUId={gadgetUId} gameType={gameType} agentId={agentId} bytes={msg.Body.Length}");
// gameType is a u32 on the wire here and a byte everywhere downstream; clamp is fine because
// the only values the client's own BowlingGameType enum defines are 0/1/2.
var narrowType = gameType <= byte.MaxValue ? (byte)gameType : BowlingWire.BowlingGameTypeSingle;
await StartBowlingZoneAsync(conn, msg.Context, gadgetUId, narrowType, agentId, "GmEnterBowlingZone");
await conn.ReturnEmptyOkAsync(msg);
}
/// <summary>
/// Shared body of both zone-entry doors (EnterBowlingZone / GmEnterBowlingZone): mint the
/// per-connection zone session, push the balls + pins as destructibles, then push the
/// SyncGameGroundZoneInfo(tag 5) payload the client's manager gates on. The caller owns the
/// RPC reply, because the two doors have different return shapes.
/// </summary>
private async Task StartBowlingZoneAsync(Connection conn, RpcContext ctx, ulong gadgetUId, byte gameType, uint agentTemplateId, string source)
{
var state = GetWorldState(ctx);
var session = GetBowlingSession(ctx);
session.GadgetUId = gadgetUId;
session.ZoneSessionId = $"bowl-{ctx.Session.Id}-{IdCounterStore.Next(BowlingZoneSeqCounter, 1)}";
session.StartedUtcTicks = DateTime.UtcNow.Ticks;
session.BallIds.Clear();
session.PinIds.Clear();
var playerPos = state.LastReportedPlayerPosition;
var lane = BowlingPickLane(playerPos.Z);
var laneHeadX = lane.GameNodeX + 0.24f;
conn.Log.Info(
$"[BOWLING] {source} gadgetUId={gadgetUId} gameType={gameType} agentTemplateId={agentTemplateId} " +
$"machine={lane.MachineNo} lane={lane.LaneNo} laneZ={lane.LaneZ:F3} " +
$"machineXZ={lane.MachineX:F2}/{lane.MachineZ:F3} " +
$"pinApex=({lane.PinApexX:F3},{BowlingPinBaseY:F2},{lane.LaneZ:F3}) " +
$"laneHead=({laneHeadX:F2},{BowlingLaneHeadY:F2},{lane.LaneZ:F3}) " +
$"player=({playerPos.X:F1},{playerPos.Y:F1},{playerPos.Z:F1}) " +
$"playerToLaneHead={MathF.Sqrt(MathF.Pow(laneHeadX - playerPos.X, 2f) + MathF.Pow(lane.LaneZ - playerPos.Z, 2f)):F2}m");
// Balls + pins first: the client's 5 s window starts as soon as the zone info lands, and the
// zone info is what tells it which ids to wait for. Both are id-stable, so the lists below
// and the destructible push describe the same objects.
await PushBowlingSceneItemsAsync(conn, session, playerPos);
var zoneInfo = new BowlingZoneInfo
{
GameType = gameType,
CurrentRound = 0,
CurrentSubRound = 0,
CurrentTurn = 0,
// Non-null EMPTY score object, deliberately NOT null. The client's own writer declares this
// member nullable=false (Base.WriteComplex(writer, val.ScoreInfo, ..., "ScoreInfo", false)),
// so the official server ALWAYS sends a body here and the client's game code is written
// against a present ScoreInfo. Sending the null marker (0x00) would still decode — the
// reader is Base.ReadComplex, which returns nil for 0x00 — but it risks a nil deref inside
// the client's bowling manager, and it is a shape the official server never emits.
ScoreInfo = new BowlingScoreInfo(),
BowlingPinSceneItemIdList = new List<ulong>(session.PinIds),
BowlingBallSceneItemIdList = new List<ulong>(session.BallIds),
ZoneSessionId = session.ZoneSessionId,
GadgetUId = gadgetUId,
TaskId = 0,
StartReason = BowlingWire.ZoneStartReasonSingle,
GameTypeRandom = false,
SyncReason = BowlingWire.ZoneSyncReasonEnter, // MUST be Enter(0): the manager branches on it
ZoneType = BowlingWire.ZoneTypeBowling, // 2 (NOT the polymorphic tag, which is 5)
ZoneState = BowlingWire.ZoneStateIdle,
ParticipantInfos = new List<BowlingParticipantInfo>(), // empty, never null
CountDownInfo = new GameGroundZoneCountDownInfo(),
};
await conn.NotifyAsync(MethodId.SyncGameGroundZoneInfo, zoneInfo);
conn.Log.Info($"[BOWLING] pushed SyncGameGroundZoneInfo tag=5 balls={session.BallIds.Count} pins={session.PinIds.Count} source={source}");
// ★ 诊断实验:把 10 个球瓶再叠一层 gadget(健身房器材那条已验证可见的通道)。
// 放在 zone info **之后**推,免得拖慢进场;见 GameRouter.BowlingPinGadget.cs。
// 默认关闭,config 里 pinAsGadget=1 才走。
await PushBowlingPinGadgetsAsync(conn, session, playerPos);
}
// ---------------------------------------------------------------------------------------
// LeaveBowling (67245027) — args: u64 gadgetuid, bool abort
// ---------------------------------------------------------------------------------------
[Handler(MethodId.LeaveBowling, HandlerPacketKind.Both)]
private async Task LeaveBowling4229938(Connection conn, UxRpcMessage msg)
{
ulong gadgetUId = 0;
bool abort = false;
if (msg.Body is { Length: >= 9 })
{
gadgetUId = BitConverter.ToUInt64(msg.Body, 0);
abort = msg.Body[8] != 0;
}
if (msg.Context.Session.Items.TryGetValue(BowlingStateKey, out var raw) && raw is BowlingSession session)
{
session.BallIds.Clear();
session.PinIds.Clear();
session.GadgetUId = 0;
}
conn.Log.Info($"[BOWLING] LeaveBowling gadgetUId={gadgetUId} abort={abort} -> Ok");
await conn.ReturnEmptyOkAsync(msg);
}
// ---------------------------------------------------------------------------------------
// In-session RPCs. These are all Invokes from ClientToGameSceneDelegate and each caller does
// .Callback = function(err) try_handle_error(err, name) end, so a missing reply stalls the loop.
// The server keeps no score book: it answers Ok and lets the client's own mini-game drive.
// (Scoring/modes belong to P2; online/agent modes belong to P3.)
// ---------------------------------------------------------------------------------------
[Handler(MethodId.RecordBowlingScore, HandlerPacketKind.Both)]
[Handler(MethodId.RecordAgentBowlingScore, HandlerPacketKind.Both)]
[Handler(MethodId.RecordAllBowlingScoreAndDrop, HandlerPacketKind.Both)]
[Handler(MethodId.AskBowlingRelease, HandlerPacketKind.Both)]
[Handler(MethodId.AskAgentBowlingRelease, HandlerPacketKind.Both)]
[Handler(MethodId.AskBowlingBuyTicket, HandlerPacketKind.Both)]
[Handler(MethodId.BroadcastBowlingClientInfo, HandlerPacketKind.Both)]
[Handler(MethodId.SetGameGroundPlayerReady, HandlerPacketKind.Both)]
[Handler(MethodId.SetGameGroundPlayerPlayAgain, HandlerPacketKind.Both)]
[Handler(MethodId.SetGameGroundPlayerSurrender, HandlerPacketKind.Both)]
[Handler(MethodId.LeaveGameGroundZone, HandlerPacketKind.Both)]
private async Task BowlingGenericAccept4229938(Connection conn, UxRpcMessage msg)
{
conn.Log.Info($"[BOWLING] {msg.MethodId} bytes={msg.Body.Length} -> Ok");
await conn.ReturnEmptyOkAsync(msg);
}
// ---------------------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------------------
/// <summary>
/// Push the bowling balls (5) and pins (10) as destructibles so the client creates + loads the
/// matching SceneItem holds. reason = AoiAddAndRemoveReason.Init (0) — the field is NOT a
/// DestructibleCreateReason; see BowlingWire.AoiReasonInit.
///
/// Why this is the right channel: mid 68969593 lands on
/// <c>LX6.Service.GameSceneToClientImpl.SyncDestructibleGridAOIDecrease(aoiInfo, addGridInfo,
/// removeGridInfo)</c>, whose <c>aoiInfo</c> is the <c>UX.Game.DestructibleGridAOIIncrease</c> this
/// file already builds — addInfos / indexList / addUniqueIds / removeIds all ride inside it. The
/// client then materialises the holds, and <c>SceneItemMgr</c> is what serves the
/// <c>GetSceneItemHold</c> lookup that BowlingGameManager.CheckDisableSceneItems gates on.
///
/// (The previous version of this comment named mid 68934309 /
/// <c>SceneItemMgr.SyncSceneItemGridAOIIncrease</c>. That method does exist on SceneItemMgr, but
/// no S2C handler on the client ever reaches it: GameSceneToClientImpl has no
/// SyncDestructibleGridAOIIncrease at all, so 68934309 is dropped on arrival. Existence of a
/// client method is NOT evidence that a method id reaches it.)
///
/// The game repositions/rents them itself afterwards (BowlingPinSetter.CreatePin ->
/// BowlingGameManager:Rent), so the spawn position only has to be close enough for the client's
/// 5 s CheckDisableSceneItems window — it is not the final lane layout. It does still have to be
/// ON THE LANE, though: the first cut spawned them relative to the player and the whole mini-game
/// played out in the alley doorway. Positions below come from the lane table measured off the
/// alley's own GameObjects (see the BowlingLanes block at the top of this file); each lane runs
/// along +x and is 2 m from its neighbour in z.
/// </summary>
private async Task PushBowlingSceneItemsAsync(Connection conn, BowlingSession session, Vec3 playerPos)
{
var (ballCfg, ballPath, pinCfg, pinPath) = BowlingWireNow();
// The lane the player is standing on (see BowlingLanes). Re-picked here so this helper is
// correct even when called from a door that did not compute it.
var lane = BowlingPickLane(playerPos.Z);
var laneHeadX = lane.GameNodeX + 0.24f;
var infos = new List<DestructibleInfo>();
var infosFF = new List<DestructibleInfoMarkFF>(); // same values, 0xFF marker (hot fallback)
var ids = new List<ulong>();
var cells = new Dictionary<(int, int), bool>();
var ballWireLog = new List<string>(); // 每槽实际推的 (cfg,path),进日志便于核对
// ★ 一批 15 个 id,批号走 IdCounterStore 落盘(见上方 BowlingSceneItemIdBase 的说明):
// 批内第 i 个 = base + i ⇒ (id - ID_BASE) % 15 == i,与客户端 BF1 补丁的
// idx = (n - ID_BASE) % BATCH 严格对齐(i<10 瓶、i>=10 球)。
var batch = (ulong)IdCounterStore.Next(BowlingSceneItemBatchCounter, 0);
var batchBase = BowlingSceneItemIdBase + batch * (ulong)BowlingSceneItemBatchSize;
var slot = 0;
void Add(float x, float y, float z, uint cfgId, int pathId, List<ulong> bucket)
{
var id = batchBase + (ulong)slot++;
// ★ The polymorphic marker byte is the one field that differs between the two variants.
// DestructibleInfo is tag-marked (see BowlingWire.DestructibleTagBase); 0xFF is only
// valid where a metatable fallback exists, which the C# dispatch of 68963429 does not have.
var pos = new UXVector3 { X = x, Y = y, Z = z };
infos.Add(new DestructibleInfo
{
InstanceId = id,
UniqueId = id,
CfgId = cfgId,
PathId = pathId,
Position = pos,
Facing = new UXVector3 { X = 0, Y = 0, Z = 0 },
IScale = 100,
NavId = 0,
ForceLod0 = false,
BindPid = 0,
Hp = 1f,
State = BowlingWire.DestructibleStateActive,
BreakStage = 0,
OccupantInfos = new List<SceneDeviceOccupantInfo>(),
DropWeaponId = 0,
StackCount = 1,
EffectIds = new List<int>(),
NoSleep = true,
ReleaserId = 0,
SkillInfo = null,
});
infosFF.Add(new DestructibleInfoMarkFF
{
InstanceId = id,
UniqueId = id,
CfgId = cfgId,
PathId = pathId,
Position = new UXVector3 { X = x, Y = y, Z = z },
Facing = new UXVector3 { X = 0, Y = 0, Z = 0 },
IScale = 100,
NavId = 0,
ForceLod0 = false,
BindPid = 0,
Hp = 1f,
State = BowlingWire.DestructibleStateActive,
BreakStage = 0,
OccupantInfos = new List<SceneDeviceOccupantInfo>(),
DropWeaponId = 0,
StackCount = 1,
EffectIds = new List<int>(),
NoSleep = true,
ReleaserId = 0,
SkillInfo = null,
});
ids.Add(id);
bucket.Add(id);
cells[((int)MathF.Floor(x / 40f), (int)MathF.Floor(z / 40f))] = true;
}
// 10 pins in a 1-2-3-4 triangle on the official pin deck. Row 0 is the HEAD PIN (nearest the
// player), each further row sits BowlingPinDepthStep closer to the back of the house and is
// one pin wider. Numbers taken from the alley's own BowlingGameNodeRight/BowlingPinPlacementR
// children (apex 2711.29 / 1590.03, lateral 0.31, depth 0.26).
int pinOrdinal = 0; // 0..9,仅在 _pinPathProbe 打开时用
for (int r = 0; r < 4; r++)
{
int n = r + 1;
for (int c = 0; c < n; c++)
{
float lateral = (c - (n - 1) * 0.5f) * BowlingPinLateralStep;
float depth = r * BowlingPinDepthStep;
// ★ 诊断:按 4/3/2/1 把 10 个球瓶分给 4 组 (cfg,path);见 _pinPathProbe 的读数对照表。
uint thisPinCfg = pinCfg;
int thisPinPath = pinPath;
if (_pinPathProbe != 0)
{
int ci = PinPathProbeCandidate(pinOrdinal);
thisPinCfg = PinPathProbeCfgs[ci];
thisPinPath = PinPathProbePaths[ci];
}
Add(lane.PinApexX + depth, BowlingPinBaseY, lane.LaneZ + lateral,
thisPinCfg, thisPinPath, session.PinIds);
pinOrdinal++;
}
}
// 5 balls in a row on the approach, just behind the foul line (the official
// BowlingSceneNode/PivotNode/BallSpawnPoint is the foul line itself).
//
// ★★ 顺序即语义:第 b 次 Add 的球会被客户端放进 sceneItemBallList[b+1],而
// CreateAnimBall/SelectBall 的 CurBallIndex 就是 1..5(= 8/10/12/14/16 磅)。
// 所以这里必须按下标取 (cfg,path),**不能**再全用同一对 —— 否则「切球看不出变化」。
// 见 BowlingWire.BallSlotCfgIds 与字段 _ballSet 的注释。
for (int b = 0; b < BowlingWire.BallSlotCount; b++)
{
float lateral = (b - 2f) * BowlingBallLateralStep;
uint thisBallCfg = ballCfg;
int thisBallPath = ballPath;
if (_ballSet == 0 && b < _ballSlotCfg.Length && b < _ballSlotPath.Length)
{
thisBallCfg = _ballSlotCfg[b];
thisBallPath = _ballSlotPath[b];
}
Add(laneHeadX - BowlingBallRackBack, BowlingLaneHeadY, lane.LaneZ + lateral,
thisBallCfg, thisBallPath, session.BallIds);
ballWireLog.Add($"{b + 1}=(lb{2 * (b + 1) + 6}:{thisBallCfg}/{thisBallPath})");
}
cells[((int)MathF.Floor(playerPos.X / 40f), (int)MathF.Floor(playerPos.Z / 40f))] = true;
var indexList = new List<SceneMethods.GridIndex>();
foreach (var c in cells.Keys) indexList.Add(new SceneMethods.GridIndex { X = c.Item1, Z = c.Item2 });
var payload = new DestructibleGridAOIIncrease
{
PlayerStandardIndex = new SceneMethods.GridIndex
{
X = (int)MathF.Floor(playerPos.X / 40f),
Z = (int)MathF.Floor(playerPos.Z / 40f),
},
AddInfos = infos,
IndexList = indexList,
AddUniqueIds = new List<ulong>(ids),
RemoveIds = new List<ulong>(),
Reason = BowlingWire.AoiReasonInit,
};
// ★★ THE channel that actually creates the holds: SyncDestructibleGridAOIDecrease (68969593).
//
// NOT SyncDestructibleGridAOIIncrease (68934309). That id is DEAD on this client build: a
// full scan of every class in every assembly (69 259 classes, frida `h4findmeth`) finds it
// only on the interface UX.Game.IGameSceneToClient and on the no-op
// UX.Game.Client.NullGameSceneToClientImpl — LX6.Service.GameSceneToClientImpl, the class
// that actually handles S2C, does not implement it. So the message was dropped silently and
// NOT ONE scene item hold was ever created (measured: SceneItemMgr held exactly one hold in
// the whole world, and that one was hand-built by a frida experiment).
//
// The live sibling carries the ADD inside `aoiInfo` and takes two plain cell specs — the
// exact same naming trap the Gadget channel documents for itself, and that channel is the
// one that already works (SyncGadgetGridAOIDecrease 68956797 = the interaction buttons).
var gridArgs = new SyncDestructibleGridAOIDecreaseArgs4229938
{
aoiInfo = payload,
addGridInfo = new SceneMethods.GridAOIDecrease
{
// Sector 0 is the base/outdoor sector. The alley cell (67,39) resolves to
// [0] / [0, 780747247] in the gadget-grid log, and its first group is [0].
SectorIdList = new List<int> { 0 },
BuildingId = 0,
StandardIndexList = indexList
.Select(c => new SceneMethods.GridIndex { X = c.X, Z = c.Z })
.ToList(),
ExceptIds = new List<ulong>(),
},
removeGridInfo = new SceneMethods.GridAOIDecrease(),
};
var gridBytes = UxSerializer.Serialize(gridArgs);
await conn.NotifyRawAsync(MethodId.SyncDestructibleGridAOIDecrease, gridBytes);
// ★ The marker byte on each item is what decides whether the client can read this at all.
// Default = the concrete base tag (1); 255 is the Lua-tree-compatible spelling and stays
// available for one in-game A/B if a future build disagrees. See BowlingWire.DestructibleTagBase.
byte[] addBytes;
string tagLabel;
if (_destructibleTag == BowlingWire.DestructibleTagAltFF)
{
addBytes = UxSerializer.Serialize(new DestructibleObjectAddListMarkFF { AddInfos = infosFF });
tagLabel = "0xFF(lua-compat)";
}
else
{
addBytes = UxSerializer.Serialize(new DestructibleObjectAddList { AddInfos = infos });
tagLabel = "1(concrete base tag)";
}
// ★ NotifyRawAsync, NOT NotifyAsync: addBytes is already a serialized UX body. Before the
// byte[] overload existed on Connection, this resolved to the generic NotifyAsync<byte[]>,
// whose UxSerializer path treats a top-level byte[] as a *buffer value* and prefixes it with
// 0xFF + Int7(length) — so the client read "1518 DestructibleInfo items" from a 1518-byte
// body, failed on item 1, and the whole scene-item layer (ball rack + pin deck) stayed
// unregistered. Empty ball pool -> AttachBallToHandr: self.currentBallGo is nil.
// ★ SIDE-CHANNEL: dump exactly what we are about to push, so a client-side patch
// (frida) can synthesise a matching SceneItemPlaceInfoData for each id. The client's
// ONLY gate into the scene-item layer is
// SceneItemBlob.TryGetSceneItemPlaceInfoDataByPlaceId(uniqueId, out MA_Ref, out blobType)
// and the official level bakes ZERO balls/pins (verified offline: cell (67,39)'s blob
// holds only 19 chairs/benches/bins; BowlingConfig.json is empty). So frida must fake
// the lookup — and to fake it faithfully it needs these numbers.
try
{
var sb = new System.Text.StringBuilder();
sb.Append("{\"t\":").Append(DateTimeOffset.UtcNow.ToUnixTimeSeconds()).Append(",\"items\":[");
for (int i = 0; i < infos.Count; i++)
{
var it = infos[i];
bool isPin = session.PinIds.Contains(it.UniqueId);
var inv = System.Globalization.CultureInfo.InvariantCulture;
if (i > 0) sb.Append(',');
sb.Append("{\"id\":").Append(it.UniqueId)
.Append(",\"cfg\":").Append(it.CfgId)
.Append(",\"path\":").Append(it.PathId)
.Append(",\"kind\":").Append(isPin ? "\"pin\"" : "\"ball\"")
.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('}');
}
sb.Append("],\"lane\":{\"machine\":").Append(lane.MachineNo)
.Append(",\"lane\":").Append(lane.LaneNo)
.Append(",\"laneZ\":").Append(lane.LaneZ.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture))
.Append("},\"player\":{\"x\":")
.Append(playerPos.X.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture))
.Append(",\"z\":")
.Append(playerPos.Z.ToString("0.####", System.Globalization.CultureInfo.InvariantCulture))
.Append("}}");
var json = sb.ToString();
foreach (var p in new[]
{
@"D:\Ananta\gym_tools\_bowling_push.json",
Path.Combine(AppContext.BaseDirectory, "config", "bowling_push.json"),
})
{
try
{
var dir = Path.GetDirectoryName(p);
if (!string.IsNullOrEmpty(dir)) Directory.CreateDirectory(dir);
File.WriteAllText(p, json);
}
catch { }
}
}
catch { }
await conn.NotifyRawAsync(MethodId.SyncDestructibleObjectAddList, addBytes);
conn.Log.Info(
$"[BOWLING] pushed destructibles balls={session.BallIds.Count} pins={session.PinIds.Count} " +
$"cells={indexList.Count} reason=AoiInit(0) machine={lane.MachineNo} lane={lane.LaneNo} " +
$"laneZ={lane.LaneZ:F3} " +
$"ballRack=({laneHeadX - BowlingBallRackBack:F2},{BowlingLaneHeadY:F2},{lane.LaneZ:F3}) " +
$"headPin=({lane.PinApexX:F3},{BowlingPinBaseY:F2},{lane.LaneZ:F3}) " +
$"wire=ballSet{_ballSet} pin({pinCfg}/{pinPath}) " +
$"balls=[{string.Join(" ", ballWireLog)}] " +
$"src={(_bowlingWireFile is { Length: > 0 } ? Path.GetFileName(_bowlingWireFile) : "compiled-in")} " +
$"gridAOIDecrease(68969593)={gridBytes.Length}b objectAddList={addBytes.Length}b " +
$"reason=Bowling(14) itemTag={tagLabel}");
}
}