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; /// /// 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 (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 /// .workbuddy/bowling_wire_spec.md 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. /// internal sealed partial class GameRouter { /// Per-connection bowling session, parked in Session.Items like the world-entry state. private const string BowlingStateKey = "client4229938-bowling"; private sealed class BowlingSession { internal ulong GadgetUId; internal string ZoneSessionId = ""; internal long StartedUtcTicks; internal readonly List BallIds = new(); internal readonly List 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_/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_/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 ), 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), ]; /// /// 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. /// 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 }; /// 把第 ordinal 个球瓶(0 起,按推送顺序)映射到候选下标。见 _pinPathProbe。 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; } /// /// Hot-reloaded bowling wire overrides. File: config/bowling_wire.json next to the /// server (same folder as private-server.json). Shape: /// {"ballCfgId":80002967,"ballPathId":1838106492,"pinCfgId":80003145,"pinPathId":1959229003,"destructibleTag":1} /// 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. /// /// destructibleTag (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. /// /// pinPathProbe (0 = off) turns on the 10-pin (cfg,path) A/B sweep described above /// ; 0 (or omitting the key) restores the plain pinCfgId/pinPathId pair. /// private static (uint ballCfg, int ballPath, uint pinCfg, int pinPath) BowlingWireNow() { try { if (_bowlingWireFile == null) { var candidates = new List(); Action 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); } /// Minimal flat {"k":number|"s"} reader — no JSON dependency in this assembly. private static IEnumerable> ParseFlatJson(string txt) { foreach (Match m in Regex.Matches(txt, "\"(?\\w+)\"\\s*:\\s*\"?(?-?\\d+)\"?")) { if (!long.TryParse(m.Groups["v"].Value, out var v)) continue; yield return new KeyValuePair( 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); } /// /// 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. /// 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(session.PinIds), BowlingBallSceneItemIdList = new List(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(), // 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 // --------------------------------------------------------------------------------------- /// /// 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 /// LX6.Service.GameSceneToClientImpl.SyncDestructibleGridAOIDecrease(aoiInfo, addGridInfo, /// removeGridInfo), whose aoiInfo is the UX.Game.DestructibleGridAOIIncrease this /// file already builds — addInfos / indexList / addUniqueIds / removeIds all ride inside it. The /// client then materialises the holds, and SceneItemMgr is what serves the /// GetSceneItemHold lookup that BowlingGameManager.CheckDisableSceneItems gates on. /// /// (The previous version of this comment named mid 68934309 / /// SceneItemMgr.SyncSceneItemGridAOIIncrease. 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. /// 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(); var infosFF = new List(); // same values, 0xFF marker (hot fallback) var ids = new List(); var cells = new Dictionary<(int, int), bool>(); var ballWireLog = new List(); // 每槽实际推的 (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 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(), DropWeaponId = 0, StackCount = 1, EffectIds = new List(), 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(), DropWeaponId = 0, StackCount = 1, EffectIds = new List(), 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(); 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(ids), RemoveIds = new List(), 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 { 0 }, BuildingId = 0, StandardIndexList = indexList .Select(c => new SceneMethods.GridIndex { X = c.X, Z = c.Z }) .ToList(), ExceptIds = new List(), }, 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, // 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}"); } }