fix(camera): align FaceCam with head 3D orientation, prevent detachment during ultimates and separate hotkeys

This commit is contained in:
CAtlas0784 committed 2026-10-31 22:47:07 +07:00
1 parent 276cb14299
commit a396f1227c
3 files changed
+339 -103

No files matched your search

+129
View File
@@ -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<br>(Dummy DLLs / dump.cs / Proto)"]
B -- No --> D{"Is it Game Data, Stats, or Formulas?"}
D -- Yes --> E["Pillar 2: Excel / Resource JSON<br>(ExcelBinOutput / Configs)"]
D -- No --> F{"Is it Protocol / Byte Wire Bug?"}
F -- Yes --> G["Pillar 3: Packet Capture & Replay<br>(*.bin payloads / Go tests)"]
F -- No --> H{"Is it State Machine / Flow Logic?"}
H -- Yes --> I["Pillar 4: Headless Unit Tests<br>(Mock Sessions / State Simulation)"]
H -- No --> J["Pillar 5: Bounded One-Shot Capture<br>(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.
+57 -13
View File
@@ -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,
+153 -90
View File
@@ -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("<color=#00FF88><b>[FaceCam: ON]</b> | F8/Insert: Close</color>")
if activeMode == "facecam" then
show_ui("<color=#00FF88><b>[FaceCam: ON]</b> | F7/Insert: Close | Scroll: Dist | F8: FreeCam</color>")
else
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look | F8/Insert: Close</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam</color>")
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('<color=#00FF88><b>[' .. label .. ': OFF]</b></color>')
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("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look | F8/Insert: Close</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam</color>")
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("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look | F8/Insert: Close</color>")
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Height | RMB: Look | F8/Insert: Close | F7: FaceCam</color>")
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