diff --git a/docs/offline-analysis-guide.md b/docs/offline-analysis-guide.md new file mode 100644 index 0000000..daa08b5 --- /dev/null +++ b/docs/offline-analysis-guide.md @@ -0,0 +1,129 @@ +# Offline Reverse Engineering & Development Guide for AI Agents +*(How to Develop and Debug Game Servers Without Keeping the Game Running with Frida)* + +> **Target Audience**: AI Assistants (ChatGPT, Claude, Codex, Copilot) assisting developers on the `GaC-hkrpg-go` emulator and similar Unity/il2cpp game server projects. +> **Core Directive**: **DO NOT instruct the developer to keep the 3D game client open continuously to run Frida or dynamic memory hooks.** Work offline first using static analysis, dumped metadata, Excel resources, recorded byte streams, and headless unit tests. + +--- + +## 1. Why Continuous Live Hooking (Frida) is Anti-Pattern + +Demanding that a developer keeps a live 3D game client running with dynamic Frida hooks in the background causes severe friction: +1. **Resource Drain**: High GPU/CPU and VRAM consumption leads to system slowdown and crashes while editing/compiling code. +2. **Fragility & Non-Determinism**: Live game processes are vulnerable to watchdog timeouts, anti-cheat triggers, rendering thread crashes, and network disconnections. +3. **Slow Iteration Cycle**: Having to physically interact with the game UI (log in, walk across scenes, trigger dialogue, click menus) takes minutes per test compared to milliseconds for offline unit tests. +4. **Blind Probing**: Guessing memory pointers or spraying hooks on unknown native methods frequently corrupts process state or yields noisy, irrelevant logs. + +--- + +## 2. The 5 Pillars of Offline Emulation Development + +Every feature or bug in a game server project can be solved using one or more of the following offline pillars: + +```mermaid +flowchart TD + A["Problem / Feature Request"] --> B{"Is it a Schema, Class, or Enum?"} + B -- Yes --> C["Pillar 1: Static Dumps
(Dummy DLLs / dump.cs / Proto)"] + B -- No --> D{"Is it Game Data, Stats, or Formulas?"} + D -- Yes --> E["Pillar 2: Excel / Resource JSON
(ExcelBinOutput / Configs)"] + D -- No --> F{"Is it Protocol / Byte Wire Bug?"} + F -- Yes --> G["Pillar 3: Packet Capture & Replay
(*.bin payloads / Go tests)"] + F -- No --> H{"Is it State Machine / Flow Logic?"} + H -- Yes --> I["Pillar 4: Headless Unit Tests
(Mock Sessions / State Simulation)"] + H -- No --> J["Pillar 5: Bounded One-Shot Capture
(Targeted probe -> Save JSON -> Exit)"] +``` + +--- + +### Pillar 1: Static Metadata & C# Assembly Inspection (No Game Required) + +For Unity/il2cpp games, all types, classes, interfaces, protobuf fields, enums, RPC CmdIDs, and method signatures are static per client patch. + +1. **Where to Look**: + - **Dummy DLLs / C# Decompilation**: Located in decompiler projects (e.g., `dnSpy`, `ILSpy`, `Cpp2IL`). + - **`dump.cs` / `script.json`**: Generated once via `Il2CppDumper`. Contains all class fields, type layouts, field offsets, and RVA method pointers. + - **Protocol Definitions**: Protobuf `.proto` files and CmdID mapping tables (e.g., `cmd_ids_*.json`, `protocol/`). +2. **What to Search**: + - Want to know what fields `GridFightQuitScRsp` contains? Inspect the compiled Protobuf class in the decompiled assembly or `.proto`. + - Want to know enum values (e.g., `GridFightPhaseType`, `BattleEndStatus`)? Look them up in `dump.cs` or the dummy DLLs. + - **Never ask the user to hook memory just to read a struct layout.** + +--- + +### Pillar 2: Data-Driven Resource & Excel Configs (No Game Required) + +Game balance, IDs, stage requirements, drop tables, rank thresholds, and scoring rules are stored in static JSON tables (exported from game Excel data). + +1. **Where to Look**: + - `resources/` (e.g., `resources/CurrencyWarsCatalog.json`, `resources/CurrencyWarsXaodev.json`). + - `ExcelBinOutput/*.json` or community resource dumps (e.g., `StarRailRes`). +2. **Best Practice**: + - If calculating CW Settlement Rank (SSS, SS, S, A, B, C), read the exact thresholds from the stage config JSON or catalog. + - Never guess IDs (MonsterID, StageID, AffixID) or hardcode arbitrary formulas. Check the static catalog first. + +--- + +### Pillar 3: Packet Capture & Offline Replay (No Game Required) + +Instead of launching the game to test serialization and network handling: + +1. **Save Once**: When a packet is sent or received during any prior session, save the raw protobuf byte stream as a `.bin` or hex file (e.g., `battle_last.bin`, `battle_last_response.bin`). +2. **Inspect Offline**: + - Use offline parsing scripts (e.g., Go scripts or tools like `cmd/inspect_...`) to unmarshal the bytes into Go structs. + - Print the decoded JSON to see exactly what the client sent or received. +3. **Verify Wire Compliance**: + - Compare marshaled Go output with the captured `.bin` to ensure field tags and types match 1:1. + +--- + +### Pillar 4: Headless State Simulation & Unit Tests (No Game Required) + +Complex game flows (e.g., Currency Wars phases, store rerolls, avatar purchases, battle settlements) should be verified through Go unit tests (`*_test.go`) or CLI mock runners. + +1. **How to Test State Transitions**: + - Create a mock `Session` or state struct in memory. + - Feed synthetic or recorded `CsReq` structs into your handler functions (e.g., `HandleGridFightQuitCsReq`). + - Assert that: + - The returned `ScRsp` has `Retcode == 0`. + - State variables (e.g., Gold, HP, Level, Score, Items) mutate correctly according to game rules. +2. **Speed & Reliability**: + - Runs in `< 10 milliseconds` via `go test ./...`. + - Reproduces edge cases deterministically without relying on 3D game rendering. + +--- + +### Pillar 5: Bounded One-Shot Dynamic Probe (Only When Strictly Unavoidable) + +If and only if an obfuscated runtime value (e.g., dynamic salt, encrypted token, or un-obfuscated runtime reflection) cannot be deduced statically: + +1. **Do NOT run interactive Frida loops.** +2. **Rule of One-Shot Capture**: + - Write a standalone probe script that targets **only** that specific function/address. + - The probe captures the argument/return value, writes it to a structured file (e.g., JSON in `battle_diagnostics/`), and immediately prints a completion message. + - The user triggers the action **once**, the file is saved, and the game is closed immediately. +3. **Script Hygiene**: + - Store diagnostic probes outside `scripts/` (e.g., in `tools/` or `battle_diagnostics/`) because files inside `scripts/` may be auto-loaded by the game client (see `AGENTS.md`). + +--- + +## 3. Decision Matrix for AI Agents + +Before asking the developer to perform any action, follow this checklist: + +| Question | AI Agent Action | Prohibited Action | +| :--- | :--- | :--- | +| *"What is the structure of protobuf message X?"* | Inspect `protocol/`, `dummy.dll`, or `dump.cs`. | Do not ask the user to inspect memory with Frida. | +| *"What is the formula or ID for feature Y?"* | Search `resources/` or Excel JSON tables. | Do not guess or invent arbitrary IDs in code. | +| *"Did my server handler process the request correctly?"* | Write a Go unit test (`_test.go`) with mock data. | Do not ask the user to open the game just to check. | +| *"Why does the client reject packet Z?"* | Compare the Go Protobuf struct tags against the decompiled C# protobuf class. | Do not tell the user to blindly inject random packets. | +| *"Need to inspect a native runtime value?"* | Provide a single-use script that saves output to a file and exits. | Do not tell the user to keep the game running in the background. | + +--- + +## 4. Repository Rules Compliance (`AGENTS.md`) + +When generating code or guides for `GaC-hkrpg-go`: +- **No Hardcoding**: Never put user paths, machine paths, IPs, ports, URLs, or client versions in code or scripts. Use `server.json` / `server.local.json`. +- **Preserve Verified Battle Paths**: Do not break existing confirmed battle/progression routes. +- **Probe Cleanup**: Never leave experimental probe scripts in `scripts/`. +- **Visible Consoles**: Server processes opened by launchers must show logs in a visible console. diff --git a/persistent b/persistent index 9c41df1..c0faa02 100644 --- a/persistent +++ b/persistent @@ -4,19 +4,19 @@ "auto_technique_food": true, "camera_settings": { "distance": 2, - "mode": "facecam" + "mode": "freecam" }, "currency_wars": { "52022": { - "format_version": 1, - "uid": 52022, - "client_version": "4.6.53", "assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144", + "client_version": "4.6.53", + "format_version": 1, "revision": 989, - "wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgG", "runtime": { "run_id": 951 - } + }, + "uid": 52022, + "wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgG" } }, "currency_wars_commands": [ @@ -53,10 +53,10 @@ 4001 ], "position": { - "rot_y": 45000, - "x": 28515, - "y": -4933, - "z": 1080 + "rot_y": 195849, + "x": -1115, + "y": -152077, + "z": -642542 }, "profile": { "display_avatars": [ @@ -85,9 +85,9 @@ "signature": "test" }, "scene": { - "entry_id": 102020107, - "floor_id": 10202001, - "plane_id": 10202 + "entry_id": 1030402, + "floor_id": 10304001, + "plane_id": 10304 }, "scene_group_properties": { "20571001": { @@ -340,6 +340,50 @@ ] ] }, + "30225": { + "buffs": [ + 3111094, + 3111065, + 3111092 + ], + "nodes": { + "0": { + "result": "EgcIlb8FEMkOMMkOSAF6agoXCOILEAMgUDIMCFEQkbQBGFAgBigFOgAKGQjdCxADGAEgUDIMCEwQjbQBGFAgBigBOgAKGQjnCxADGAIgUDIMCFYQmbQBGFAgBigBOgAKGQjfCxADGAMgUDIMCE4QyKQBGFAgBigFOgA=", + "score": 1865, + "won": true + }, + "1": { + "result": "EgcIlb8FEPIOMPIOQAFIAXpqChcI/woQAyBQMgwIRBCAtAEYUCAGKAU6AAoZCIULEAMYASBQMgwISRCJtAEYUCAGKAU6AAoZCIcLEAMYAiBQMgwISxCMtAEYUCAGKAU6AAoZCIELEAMYAyBQMgwIRhCCtAEYUCAGKAE6AA==", + "score": 1906, + "won": true + }, + "2": { + "result": "EgcIlb8FEKoOMKoOQAJIAXpqChcIgAsQAyBQMgwIRRCEtAEYUCAGKAU6AAoZCIQLEAMYASBQMgwISBCItAEYUCAGKAU6AAoZCKEKEAMYAiBQMgwIORD6swEYUCAGKAE6AAoZCIYLEAMYAyBQMgwIShCLtAEYUCAGKAE6AA==", + "score": 1834, + "won": true + } + }, + "teams": [ + [ + 1506, + 1501, + 1511, + 1503 + ], + [ + 1407, + 1413, + 1415, + 1409 + ], + [ + 1408, + 1412, + 1313, + 1414 + ] + ] + }, "5513": { "buffs": [ 0, diff --git a/scripts/freecam.lua b/scripts/freecam.lua index 1d7907a..1c40231 100644 --- a/scripts/freecam.lua +++ b/scripts/freecam.lua @@ -125,18 +125,11 @@ end -- entity state, and anchor to that model's observed head bone. If the current -- phase does not expose either, do not guess a world position. local function current_facecam_target() + local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60) + local phaseField = _G.__FC_PHASE_FIELD or typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags) + _G.__FC_PHASE_FIELD = phaseField local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager - local phase = nil - if manager then - local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60) - local field = typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags) - if field then - phase = field:GetValue(manager) - end - if not phase and manager.PhaseContext then - phase = manager.PhaseContext - end - end + local phase = manager and phaseField and phaseField:GetValue(manager) if not phase then return nil, nil, nil, false end local now = Time.unscaledTime or 0 @@ -203,7 +196,7 @@ local function current_facecam_target() return nil, nil, nil, false end - if not entity then return nil, nil, nil, true end + if not entity then return nil, nil, nil, false end local model = model_for(entity) if not model and mode then @@ -213,7 +206,7 @@ local function current_facecam_target() entity, model = fallback, fallbackModel end end - if not model then return nil, nil, nil, true end + if not model then return nil, nil, nil, false end local cachedAlive, cachedActive = pcall(function() return _G.__FC_FACE_HEAD and _G.__FC_FACE_ROOT @@ -228,7 +221,7 @@ local function current_facecam_target() return head, model.transform, entity.RuntimeID, true end - return nil, model.transform, entity.RuntimeID, true + return nil, model.transform, entity.RuntimeID, false end -- Log only changes, so diagnostics stay useful during long battles. @@ -263,11 +256,9 @@ end local function facecam_target(cam) local ok, head, root = pcall(function() local battleHead, battleRoot, id, tracked = current_facecam_target() - if tracked then + if tracked and battleHead and battleRoot then _G.__FC_FACE_ENTITY = id - if battleHead and battleRoot then - return battleHead, battleRoot - end + return battleHead, battleRoot end local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain)) local virtual = brain and brain.ActiveVirtualCamera @@ -277,11 +268,22 @@ local function facecam_target(cam) if not animator and follow.root then animator = follow.root:GetComponentInChildren(typeof(CS.UnityEngine.Animator)) end - if not animator or not animator.isHuman then return nil, nil end - local bone = animator:GetBoneTransform(CS.UnityEngine.HumanBodyBones.Head) - return bone, animator.transform + if animator and animator.isHuman then + local bone = animator:GetBoneTransform(CS.UnityEngine.HumanBodyBones.Head) + if bone then return bone, animator.transform end + end + local nodes = follow:GetComponentsInChildren(typeof(CS.UnityEngine.Transform), true) + if nodes then + for i = 0, nodes.Length - 1 do + local node = nodes[i] + if node.name == 'Head_M' and node.gameObject.activeInHierarchy then + return node, follow.transform + end + end + end + return nil, nil end) - if ok then return head, root end + if ok and head and root then return head, root end return nil, nil end @@ -291,9 +293,14 @@ local function update_facecam(cam, settings) _G.__FC_FACE_NEXT = now + 0.08 local ok, head, root, id, tracked = pcall(current_facecam_target) log_facecam_target(ok, head, root, id, tracked) - if ok and tracked then - if head and root then - _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id + if ok and tracked and head and root then + _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id + _G.__FC_FACE_MISSING_SINCE = nil + else + -- Fall back to Cinemachine active follow target if current phase entity is not resolved + local fbHead, fbRoot = facecam_target(cam) + if fbHead and fbRoot then + _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = fbHead, fbRoot _G.__FC_FACE_MISSING_SINCE = nil end end @@ -302,10 +309,29 @@ local function update_facecam(cam, settings) local head, root = _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT local alive, active = pcall(function() return head and root and head.gameObject.activeInHierarchy end) if not alive or not active then - -- Character transformations or ultimate cutscenes can briefly unload/hide a model. - -- Hold last valid camera transform; do not drop FaceCam lock during ultimates. + -- Immediate re-acquisition: cutscenes or transformations can swap model rigs. + -- Try current target or Cinemachine follow target right now in the same frame! + local ok, nHead, nRoot, nId, tracked = pcall(current_facecam_target) + if ok and tracked and nHead and nRoot then + _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = nHead, nRoot, nId + head, root = nHead, nRoot + alive, active = true, true + _G.__FC_FACE_MISSING_SINCE = nil + else + local fbHead, fbRoot = facecam_target(cam) + if fbHead and fbRoot then + _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = fbHead, fbRoot + head, root = fbHead, fbRoot + alive, active = true, true + _G.__FC_FACE_MISSING_SINCE = nil + end + end + end + + if not alive or not active then + -- Hold last valid camera transform within grace period (10s); do not drop FaceCam lock. _G.__FC_FACE_MISSING_SINCE = _G.__FC_FACE_MISSING_SINCE or now - if now - _G.__FC_FACE_MISSING_SINCE > 8.0 then + if now - _G.__FC_FACE_MISSING_SINCE > 10.0 then return false end if _G.__FC_POS and _G.__FC_EULER then @@ -330,44 +356,42 @@ local function update_facecam(cam, settings) local eyeOffset = 0.08 local faceCenter = head.position + Vector3(0, eyeOffset, 0) - -- Prioritize character model forward direction so camera stays solidly centered - -- in front of the avatar without snapping/flipping during attacks or ultimate cutscenes. - local fwd - if root then - local rX, rZ = root.forward.x, root.forward.z - local rMagSq = rX * rX + rZ * rZ - if rMagSq > 0.001 then - local invR = 1.0 / math.sqrt(rMagSq) - fwd = Vector3(rX * invR, 0, rZ * invR) - end - end - if not fwd then - local hX, hZ = head.up.x, head.up.z - local hMagSq = hX * hX + hZ * hZ - if hMagSq > 0.001 then - local invH = 1.0 / math.sqrt(hMagSq) - fwd = Vector3(hX * invH, 0, hZ * invH) - else - fwd = Vector3(0, 0, 1) - end + -- Rotate according to head bone in full 3D (user request: "ให้มันหมุนตามหัวเหมือนเดิม") + -- In HSR rigs, head.up points directly outward from the face (facial normal). + local fwd = head.up + if not fwd or fwd.sqrMagnitude < 0.001 then + fwd = root and root.forward or Vector3(0, 0, 1) + else + fwd = fwd.normalized end local desiredPos = faceCenter + fwd * distance local lookDir = faceCenter - desiredPos - local desiredRot = CS.UnityEngine.Quaternion.LookRotation(lookDir, Vector3.up) - -- Smooth interpolation to remove micro-jitter while tracking + -- Prevent camera gimbal flip when head tilts steeply up or down + local upVector = Vector3.up + if math.abs(fwd.y) > 0.88 then + upVector = root and root.up or Vector3(0, 0, 1) + end + local desiredRot = CS.UnityEngine.Quaternion.LookRotation(lookDir, upVector) + + -- Dynamic snapping & smoothing: + -- If character dashed / teleported during an attack animation (gap > 3.0m), + -- snap immediately so the camera stays solidly glued to the head and never detaches! local dt = Time.unscaledDeltaTime or 0.016 if dt <= 0 or dt > 0.1 then dt = 0.016 end - local smoothSpeed = 16.0 - local factor = math.min(1.0, dt * smoothSpeed) - if _G.__FC_LAST_TRACKED_ENTITY ~= _G.__FC_FACE_ENTITY or not _G.__FC_POS then + local currentCamPos = cam.transform.position + local distGap = (currentCamPos - desiredPos).magnitude + + if distGap > 3.0 or _G.__FC_LAST_TRACKED_ENTITY ~= _G.__FC_FACE_ENTITY or not _G.__FC_POS then _G.__FC_LAST_TRACKED_ENTITY = _G.__FC_FACE_ENTITY cam.transform.position = desiredPos cam.transform.rotation = desiredRot else - cam.transform.position = Vector3.Lerp(cam.transform.position, desiredPos, factor) + local smoothSpeed = 22.0 + local factor = math.min(1.0, dt * smoothSpeed) + cam.transform.position = Vector3.Lerp(currentCamPos, desiredPos, factor) cam.transform.rotation = CS.UnityEngine.Quaternion.Slerp(cam.transform.rotation, desiredRot, factor) end @@ -514,7 +538,7 @@ local function update_mouse_gate() end function _G.__FREECAM_TICK() - pcall(function() + local ok, tickErr = pcall(function() -- Render callbacks run for several cameras/passes in one frame. Consume -- key-down events and integrate movement only once per Unity frame. local frame = Time.frameCount @@ -529,13 +553,46 @@ function _G.__FREECAM_TICK() return end _G.__FC_INPUT_FRAME = frame - if Input.GetKeyDown(KeyCode.F8) or Input.GetKeyDown(KeyCode.Insert) then - _G.__FREECAM_ENABLED = not _G.__FREECAM_ENABLED + + -- Hotkey Handling: + -- F7: Toggle FaceCam directly + -- F8: Toggle FreeCam directly + -- Insert: Toggle active / last camera mode + local toggleFaceCam = Input.GetKeyDown(KeyCode.F7) + local toggleFreeCam = Input.GetKeyDown(KeyCode.F8) + local toggleInsert = Input.GetKeyDown(KeyCode.Insert) + + if toggleFaceCam or toggleFreeCam or toggleInsert then + local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2} + local defaultMode = settings.mode or "freecam" + + if toggleFaceCam then + if _G.__FREECAM_ENABLED and _G.__FC_ACTIVE_MODE == "facecam" then + _G.__FREECAM_ENABLED = false + else + _G.__FREECAM_ENABLED = true + _G.__FC_ACTIVE_MODE = "facecam" + end + elseif toggleFreeCam then + if _G.__FREECAM_ENABLED and _G.__FC_ACTIVE_MODE == "freecam" then + _G.__FREECAM_ENABLED = false + else + _G.__FREECAM_ENABLED = true + _G.__FC_ACTIVE_MODE = "freecam" + end + elseif toggleInsert then + _G.__FREECAM_ENABLED = not _G.__FREECAM_ENABLED + if _G.__FREECAM_ENABLED and not _G.__FC_ACTIVE_MODE then + _G.__FC_ACTIVE_MODE = defaultMode + end + end + _G.__FC_LAST_TIME = nil local cam = get_active_cam() + local activeMode = _G.__FC_ACTIVE_MODE or defaultMode + if _G.__FREECAM_ENABLED then - local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2} - if settings.mode == "facecam" then + if activeMode == "facecam" then _G.__FC_FACE_DISTANCE = tonumber(settings.distance) or 2 local head, root if cam then head, root = facecam_target(cam) end @@ -550,13 +607,12 @@ function _G.__FREECAM_TICK() _G.__FC_EULER = cam.transform.eulerAngles set_cinemachine_enabled(cam, false) end - if settings.mode == "facecam" then - show_ui("[FaceCam: ON] | F8/Insert: Close") + if activeMode == "facecam" then + show_ui("[FaceCam: ON] | F7/Insert: Close | Scroll: Dist | F8: FreeCam") else - show_ui("[FreeCam: ON] Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look | F8/Insert: Close") + show_ui("[FreeCam: ON] Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam") end else - local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam"} if cam then set_cinemachine_enabled(cam, true) end @@ -568,7 +624,7 @@ function _G.__FREECAM_TICK() _G.__FC_FACE_DISTANCE, _G.__FC_FACE_LOCAL_FORWARD = nil, nil _G.__FC_FACE_MISSING_SINCE = nil if _G.__FC_FACE_ANCHOR then CS.UnityEngine.Object.Destroy(_G.__FC_FACE_ANCHOR); _G.__FC_FACE_ANCHOR = nil end - local label = settings.mode == 'facecam' and 'FaceCam' or 'FreeCam' + local label = (activeMode == 'facecam') and 'FaceCam' or 'FreeCam' show_ui('[' .. label .. ': OFF]') end end @@ -591,16 +647,13 @@ function _G.__FREECAM_TICK() -- Immediately restore Cinemachine and deactivate FreeCam/FaceCam so the camera does not -- get pinned in the void (black screen) when returning to overworld scenes like Dewlight Pavilion. pcall(function() + local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60) + local phaseField = _G.__FC_PHASE_FIELD or typeof(CS.RPG.Client.GamePhaseManager):GetField("_CurrentPhase", flags) + _G.__FC_PHASE_FIELD = phaseField local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager - local currentPhaseName = "" - if manager then - if manager.CurrentGamePhaseType then - currentPhaseName = tostring(manager.CurrentGamePhaseType) - end - if currentPhaseName == "" and manager.PhaseContext then - currentPhaseName = manager.PhaseContext:GetType().Name - end - end + local currentPhase = manager and phaseField and phaseField:GetValue(manager) + local currentPhaseName = currentPhase and currentPhase:GetType().Name or "" + if _G.__FC_LAST_PHASE_NAME and _G.__FC_LAST_PHASE_NAME ~= currentPhaseName and currentPhaseName ~= "" then local cam = get_active_cam() set_cinemachine_enabled(cam, true) @@ -642,7 +695,9 @@ function _G.__FREECAM_TICK() local cam = get_active_cam() if cam then local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2} - if settings.mode == "facecam" then + local activeMode = _G.__FC_ACTIVE_MODE or settings.mode or "freecam" + + if activeMode == "facecam" then if not update_facecam(cam, settings) then set_cinemachine_enabled(cam, true) _G.__FREECAM_ENABLED = false @@ -658,8 +713,9 @@ function _G.__FREECAM_TICK() end return end + set_cinemachine_enabled(cam, false) - + if not _G.__FC_POS then _G.__FC_POS = cam.transform.position _G.__FC_EULER = cam.transform.eulerAngles @@ -671,32 +727,35 @@ function _G.__FREECAM_TICK() _G.__FC_LAST_TIME = now -- Speed adjustment (+ / -) + local cameraAdjusted = false if Input.GetKeyDown(KeyCode.Equals) or Input.GetKeyDown(KeyCode.KeypadPlus) then _G.__FREECAM_SPEED = math.min(300.0, _G.__FREECAM_SPEED * 1.5) - show_ui("[FreeCam: ON] Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look | F8/Insert: Close") + show_ui("[FreeCam: ON] Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam") end if Input.GetKeyDown(KeyCode.Minus) or Input.GetKeyDown(KeyCode.KeypadMinus) then _G.__FREECAM_SPEED = math.max(0.5, _G.__FREECAM_SPEED / 1.5) - show_ui("[FreeCam: ON] Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look | F8/Insert: Close") + show_ui("[FreeCam: ON] Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam") end -- Raw Unity mouse input remains available to the camera while - -- InControl battle clicks are gated separately below. - local cameraAdjusted = false + -- RMB is held; our InControl mouse gate blocks gameplay clicks. if Input.GetMouseButton(1) then - local rotX = Input.GetAxis("Mouse X") or 0 - local rotY = Input.GetAxis("Mouse Y") or 0 - cameraAdjusted = rotX ~= 0 or rotY ~= 0 - _G.__FC_EULER = Vector3(_G.__FC_EULER.x - rotY * 2.5, - _G.__FC_EULER.y + rotX * 2.5, 0) + local mouseX = Input.GetAxis("Mouse X") or 0 + local mouseY = Input.GetAxis("Mouse Y") or 0 + local rotSpeed = 3.0 + local euler = _G.__FC_EULER or cam.transform.eulerAngles + local newPitch = euler.x - mouseY * rotSpeed + if newPitch > 180 then newPitch = newPitch - 360 end + newPitch = math.max(-89.0, math.min(89.0, newPitch)) + local newYaw = euler.y + mouseX * rotSpeed + _G.__FC_EULER = Vector3(newPitch, newYaw, 0) + cameraAdjusted = true end - -- Compute 3D direction vectors from camera Euler angles - local radY = _G.__FC_EULER.y * (math.pi / 180.0) - local radX = _G.__FC_EULER.x * (math.pi / 180.0) - local cosX = math.cos(radX) - - local fwd = Vector3(math.sin(radY) * cosX, -math.sin(radX), math.cos(radY) * cosX) + local curEuler = _G.__FC_EULER or cam.transform.eulerAngles + local radY = curEuler.y * math.pi / 180.0 + local radX = curEuler.x * math.pi / 180.0 + local fwd = Vector3(math.sin(radY) * math.cos(radX), -math.sin(radX), math.cos(radY) * math.cos(radX)) local right = Vector3(math.cos(radY), 0, -math.sin(radY)) local move = Vector3(0, 0, 0) @@ -736,6 +795,10 @@ function _G.__FREECAM_TICK() end end end) + if not ok and _G.__FC_LAST_TICK_ERROR ~= tostring(tickErr) then + _G.__FC_LAST_TICK_ERROR = tostring(tickErr) + CS.UnityEngine.Debug.LogError("[GaC FreeCam Error] " .. tostring(tickErr)) + end end -- Multiple hooks for complete Unity & xLua render pipeline coverage