944 lines
56 KiB
C#
944 lines
56 KiB
C#
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}");
|
|||
|
|
}
|
|||
|
|
}
|