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
+333 -97

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, "auto_technique_food": true,
"camera_settings": { "camera_settings": {
"distance": 2, "distance": 2,
"mode": "facecam" "mode": "freecam"
}, },
"currency_wars": { "currency_wars": {
"52022": { "52022": {
"format_version": 1,
"uid": 52022,
"client_version": "4.6.53",
"assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144", "assembly_sha256": "c5e02e6e984f7e5577c78b995a8efeee2d06ee22e67d484ad5e859c8bf21e144",
"client_version": "4.6.53",
"format_version": 1,
"revision": 989, "revision": 989,
"wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgG",
"runtime": { "runtime": {
"run_id": 951 "run_id": 951
} },
"uid": 52022,
"wire_data": "GkBCHAoAMgoIARIGCCE4BmgBMgoIAhIGCEg4BmgCWAJiIBCFnQEgAkoQGgAiACoAMgA6AFoAcgB6AFIAWAFyAHgG"
} }
}, },
"currency_wars_commands": [ "currency_wars_commands": [
@@ -53,10 +53,10 @@
4001 4001
], ],
"position": { "position": {
"rot_y": 45000, "rot_y": 195849,
"x": 28515, "x": -1115,
"y": -4933, "y": -152077,
"z": 1080 "z": -642542
}, },
"profile": { "profile": {
"display_avatars": [ "display_avatars": [
@@ -85,9 +85,9 @@
"signature": "test" "signature": "test"
}, },
"scene": { "scene": {
"entry_id": 102020107, "entry_id": 1030402,
"floor_id": 10202001, "floor_id": 10304001,
"plane_id": 10202 "plane_id": 10304
}, },
"scene_group_properties": { "scene_group_properties": {
"20571001": { "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": { "5513": {
"buffs": [ "buffs": [
0, 0,
+147 -84
View File
@@ -125,18 +125,11 @@ end
-- entity state, and anchor to that model's observed head bone. If the current -- 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. -- phase does not expose either, do not guess a world position.
local function current_facecam_target() local function current_facecam_target()
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 flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
local field = typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags) local phaseField = _G.__FC_PHASE_FIELD or typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags)
if field then _G.__FC_PHASE_FIELD = phaseField
phase = field:GetValue(manager) local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
end local phase = manager and phaseField and phaseField:GetValue(manager)
if not phase and manager.PhaseContext then
phase = manager.PhaseContext
end
end
if not phase then return nil, nil, nil, false end if not phase then return nil, nil, nil, false end
local now = Time.unscaledTime or 0 local now = Time.unscaledTime or 0
@@ -203,7 +196,7 @@ local function current_facecam_target()
return nil, nil, nil, false return nil, nil, nil, false
end 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) local model = model_for(entity)
if not model and mode then if not model and mode then
@@ -213,7 +206,7 @@ local function current_facecam_target()
entity, model = fallback, fallbackModel entity, model = fallback, fallbackModel
end end
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() local cachedAlive, cachedActive = pcall(function()
return _G.__FC_FACE_HEAD and _G.__FC_FACE_ROOT 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 return head, model.transform, entity.RuntimeID, true
end end
return nil, model.transform, entity.RuntimeID, true return nil, model.transform, entity.RuntimeID, false
end end
-- Log only changes, so diagnostics stay useful during long battles. -- Log only changes, so diagnostics stay useful during long battles.
@@ -263,12 +256,10 @@ end
local function facecam_target(cam) local function facecam_target(cam)
local ok, head, root = pcall(function() local ok, head, root = pcall(function()
local battleHead, battleRoot, id, tracked = current_facecam_target() local battleHead, battleRoot, id, tracked = current_facecam_target()
if tracked then if tracked and battleHead and battleRoot then
_G.__FC_FACE_ENTITY = id _G.__FC_FACE_ENTITY = id
if battleHead and battleRoot then
return battleHead, battleRoot return battleHead, battleRoot
end end
end
local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain)) local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
local virtual = brain and brain.ActiveVirtualCamera local virtual = brain and brain.ActiveVirtualCamera
local follow = virtual and virtual.Follow local follow = virtual and virtual.Follow
@@ -277,11 +268,22 @@ local function facecam_target(cam)
if not animator and follow.root then if not animator and follow.root then
animator = follow.root:GetComponentInChildren(typeof(CS.UnityEngine.Animator)) animator = follow.root:GetComponentInChildren(typeof(CS.UnityEngine.Animator))
end end
if not animator or not animator.isHuman then return nil, nil end if animator and animator.isHuman then
local bone = animator:GetBoneTransform(CS.UnityEngine.HumanBodyBones.Head) local bone = animator:GetBoneTransform(CS.UnityEngine.HumanBodyBones.Head)
return bone, animator.transform 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) end)
if ok then return head, root end if ok and head and root then return head, root end
return nil, nil return nil, nil
end end
@@ -291,10 +293,15 @@ local function update_facecam(cam, settings)
_G.__FC_FACE_NEXT = now + 0.08 _G.__FC_FACE_NEXT = now + 0.08
local ok, head, root, id, tracked = pcall(current_facecam_target) local ok, head, root, id, tracked = pcall(current_facecam_target)
log_facecam_target(ok, head, root, id, tracked) log_facecam_target(ok, head, root, id, tracked)
if ok and tracked then if ok and tracked and head and root then
if head and root then
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id
_G.__FC_FACE_MISSING_SINCE = nil _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
end end
end end
@@ -302,10 +309,29 @@ local function update_facecam(cam, settings)
local head, root = _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT 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) local alive, active = pcall(function() return head and root and head.gameObject.activeInHierarchy end)
if not alive or not active then if not alive or not active then
-- Character transformations or ultimate cutscenes can briefly unload/hide a model. -- Immediate re-acquisition: cutscenes or transformations can swap model rigs.
-- Hold last valid camera transform; do not drop FaceCam lock during ultimates. -- 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 _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 return false
end end
if _G.__FC_POS and _G.__FC_EULER then if _G.__FC_POS and _G.__FC_EULER then
@@ -330,44 +356,42 @@ local function update_facecam(cam, settings)
local eyeOffset = 0.08 local eyeOffset = 0.08
local faceCenter = head.position + Vector3(0, eyeOffset, 0) local faceCenter = head.position + Vector3(0, eyeOffset, 0)
-- Prioritize character model forward direction so camera stays solidly centered -- Rotate according to head bone in full 3D (user request: "ให้มันหมุนตามหัวเหมือนเดิม")
-- in front of the avatar without snapping/flipping during attacks or ultimate cutscenes. -- In HSR rigs, head.up points directly outward from the face (facial normal).
local fwd local fwd = head.up
if root then if not fwd or fwd.sqrMagnitude < 0.001 then
local rX, rZ = root.forward.x, root.forward.z fwd = root and root.forward or Vector3(0, 0, 1)
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 else
fwd = Vector3(0, 0, 1) fwd = fwd.normalized
end
end end
local desiredPos = faceCenter + fwd * distance local desiredPos = faceCenter + fwd * distance
local lookDir = faceCenter - desiredPos 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 local dt = Time.unscaledDeltaTime or 0.016
if dt <= 0 or dt > 0.1 then dt = 0.016 end 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 _G.__FC_LAST_TRACKED_ENTITY = _G.__FC_FACE_ENTITY
cam.transform.position = desiredPos cam.transform.position = desiredPos
cam.transform.rotation = desiredRot cam.transform.rotation = desiredRot
else 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) cam.transform.rotation = CS.UnityEngine.Quaternion.Slerp(cam.transform.rotation, desiredRot, factor)
end end
@@ -514,7 +538,7 @@ local function update_mouse_gate()
end end
function _G.__FREECAM_TICK() function _G.__FREECAM_TICK()
pcall(function() local ok, tickErr = pcall(function()
-- Render callbacks run for several cameras/passes in one frame. Consume -- Render callbacks run for several cameras/passes in one frame. Consume
-- key-down events and integrate movement only once per Unity frame. -- key-down events and integrate movement only once per Unity frame.
local frame = Time.frameCount local frame = Time.frameCount
@@ -529,13 +553,46 @@ function _G.__FREECAM_TICK()
return return
end end
_G.__FC_INPUT_FRAME = frame _G.__FC_INPUT_FRAME = frame
if Input.GetKeyDown(KeyCode.F8) or Input.GetKeyDown(KeyCode.Insert) then
-- 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 _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 _G.__FC_LAST_TIME = nil
local cam = get_active_cam() local cam = get_active_cam()
local activeMode = _G.__FC_ACTIVE_MODE or defaultMode
if _G.__FREECAM_ENABLED then if _G.__FREECAM_ENABLED then
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2} if activeMode == "facecam" then
if settings.mode == "facecam" then
_G.__FC_FACE_DISTANCE = tonumber(settings.distance) or 2 _G.__FC_FACE_DISTANCE = tonumber(settings.distance) or 2
local head, root local head, root
if cam then head, root = facecam_target(cam) end if cam then head, root = facecam_target(cam) end
@@ -550,13 +607,12 @@ function _G.__FREECAM_TICK()
_G.__FC_EULER = cam.transform.eulerAngles _G.__FC_EULER = cam.transform.eulerAngles
set_cinemachine_enabled(cam, false) set_cinemachine_enabled(cam, false)
end end
if settings.mode == "facecam" then if activeMode == "facecam" then
show_ui("<color=#00FF88><b>[FaceCam: ON]</b> | F8/Insert: Close</color>") show_ui("<color=#00FF88><b>[FaceCam: ON]</b> | F7/Insert: Close | Scroll: Dist | F8: FreeCam</color>")
else 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 end
else else
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam"}
if cam then if cam then
set_cinemachine_enabled(cam, true) set_cinemachine_enabled(cam, true)
end end
@@ -568,7 +624,7 @@ function _G.__FREECAM_TICK()
_G.__FC_FACE_DISTANCE, _G.__FC_FACE_LOCAL_FORWARD = nil, nil _G.__FC_FACE_DISTANCE, _G.__FC_FACE_LOCAL_FORWARD = nil, nil
_G.__FC_FACE_MISSING_SINCE = 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 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>') show_ui('<color=#00FF88><b>[' .. label .. ': OFF]</b></color>')
end end
end end
@@ -591,16 +647,13 @@ function _G.__FREECAM_TICK()
-- Immediately restore Cinemachine and deactivate FreeCam/FaceCam so the camera does not -- 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. -- get pinned in the void (black screen) when returning to overworld scenes like Dewlight Pavilion.
pcall(function() 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 manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
local currentPhaseName = "" local currentPhase = manager and phaseField and phaseField:GetValue(manager)
if manager then local currentPhaseName = currentPhase and currentPhase:GetType().Name or ""
if manager.CurrentGamePhaseType then
currentPhaseName = tostring(manager.CurrentGamePhaseType)
end
if currentPhaseName == "" and manager.PhaseContext then
currentPhaseName = manager.PhaseContext:GetType().Name
end
end
if _G.__FC_LAST_PHASE_NAME and _G.__FC_LAST_PHASE_NAME ~= currentPhaseName and currentPhaseName ~= "" then if _G.__FC_LAST_PHASE_NAME and _G.__FC_LAST_PHASE_NAME ~= currentPhaseName and currentPhaseName ~= "" then
local cam = get_active_cam() local cam = get_active_cam()
set_cinemachine_enabled(cam, true) set_cinemachine_enabled(cam, true)
@@ -642,7 +695,9 @@ function _G.__FREECAM_TICK()
local cam = get_active_cam() local cam = get_active_cam()
if cam then if cam then
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2} 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 if not update_facecam(cam, settings) then
set_cinemachine_enabled(cam, true) set_cinemachine_enabled(cam, true)
_G.__FREECAM_ENABLED = false _G.__FREECAM_ENABLED = false
@@ -658,6 +713,7 @@ function _G.__FREECAM_TICK()
end end
return return
end end
set_cinemachine_enabled(cam, false) set_cinemachine_enabled(cam, false)
if not _G.__FC_POS then if not _G.__FC_POS then
@@ -671,32 +727,35 @@ function _G.__FREECAM_TICK()
_G.__FC_LAST_TIME = now _G.__FC_LAST_TIME = now
-- Speed adjustment (+ / -) -- Speed adjustment (+ / -)
local cameraAdjusted = false
if Input.GetKeyDown(KeyCode.Equals) or Input.GetKeyDown(KeyCode.KeypadPlus) then if Input.GetKeyDown(KeyCode.Equals) or Input.GetKeyDown(KeyCode.KeypadPlus) then
_G.__FREECAM_SPEED = math.min(300.0, _G.__FREECAM_SPEED * 1.5) _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 end
if Input.GetKeyDown(KeyCode.Minus) or Input.GetKeyDown(KeyCode.KeypadMinus) then if Input.GetKeyDown(KeyCode.Minus) or Input.GetKeyDown(KeyCode.KeypadMinus) then
_G.__FREECAM_SPEED = math.max(0.5, _G.__FREECAM_SPEED / 1.5) _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 end
-- Raw Unity mouse input remains available to the camera while -- Raw Unity mouse input remains available to the camera while
-- InControl battle clicks are gated separately below. -- RMB is held; our InControl mouse gate blocks gameplay clicks.
local cameraAdjusted = false
if Input.GetMouseButton(1) then if Input.GetMouseButton(1) then
local rotX = Input.GetAxis("Mouse X") or 0 local mouseX = Input.GetAxis("Mouse X") or 0
local rotY = Input.GetAxis("Mouse Y") or 0 local mouseY = Input.GetAxis("Mouse Y") or 0
cameraAdjusted = rotX ~= 0 or rotY ~= 0 local rotSpeed = 3.0
_G.__FC_EULER = Vector3(_G.__FC_EULER.x - rotY * 2.5, local euler = _G.__FC_EULER or cam.transform.eulerAngles
_G.__FC_EULER.y + rotX * 2.5, 0) 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 end
-- Compute 3D direction vectors from camera Euler angles local curEuler = _G.__FC_EULER or cam.transform.eulerAngles
local radY = _G.__FC_EULER.y * (math.pi / 180.0) local radY = curEuler.y * math.pi / 180.0
local radX = _G.__FC_EULER.x * (math.pi / 180.0) local radX = curEuler.x * math.pi / 180.0
local cosX = math.cos(radX) local fwd = Vector3(math.sin(radY) * math.cos(radX), -math.sin(radX), math.cos(radY) * math.cos(radX))
local fwd = Vector3(math.sin(radY) * cosX, -math.sin(radX), math.cos(radY) * cosX)
local right = Vector3(math.cos(radY), 0, -math.sin(radY)) local right = Vector3(math.cos(radY), 0, -math.sin(radY))
local move = Vector3(0, 0, 0) local move = Vector3(0, 0, 0)
@@ -736,6 +795,10 @@ function _G.__FREECAM_TICK()
end end
end 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 end
-- Multiple hooks for complete Unity & xLua render pipeline coverage -- Multiple hooks for complete Unity & xLua render pipeline coverage