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