Compare commits
7
Commits
0cbe460e49
...
v1.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
30bee81fe9 | ||
|
|
67a819a4d3 | ||
|
|
1fa9504176 | ||
|
|
448ea61a68 | ||
|
|
3fbb70a30c | ||
|
|
85946a3996 | ||
|
|
cd6d8445f2 |
No files matched your search
@@ -6,6 +6,14 @@
|
||||
|
||||
## CW — กำลังพัฒนา
|
||||
|
||||
คำสั่งเวลาใน GaC-Srtool: `/faketime status` ดูเวลาเกม, `/faketime <Unix seconds>` หรือ `/faketime <วันที่ RFC3339 พร้อม timezone>` ตั้งเวลาจำลอง และ `/faketime off` คืนเวลาจริง เวลาจำลองเดินต่อและบันทึกใน `game_clock.state_file` จาก config (override ด้วย `GAC_GAME_CLOCK_STATE_FILE`) ใช้กับ Login/heartbeat; ไม่เปลี่ยนนาฬิกา Windows, network timeout หรือเวลา log หลังเปลี่ยนให้เปิดสมุดใหม่ หรือเข้าเกมใหม่หากหน้าจอเก็บตารางเวลาไว้
|
||||
|
||||
ฉาก Login รถไฟ Cyrene ใช้งานได้แล้ว โดยใช้ `LoginShow_UP` เปิด manifest ชุดเต็มตั้งแต่เริ่มเกม และผูก event จบกับคลิปแอนิเมชัน UP ผู้ใช้ยืนยันทั้งภาพรถไฟสีขาว ฉากชมพูม่วง กลีบดอกไม้ และการ Logout แล้วกลับเข้าใหม่แล้ว เครื่องมือสร้างชุดสำเนาและคืนค่าต้นฉบับ รวมถึงผลตรวจในเกมอยู่ใน [บันทึกฉาก Login](docs/login-cyrene-investigation.md)
|
||||
|
||||
ตัวเลือก `lua_overlay` ปิดอยู่ตามปกติ หากเปิดทดลองต้องกำหนด `stage_dir`, `hotfix_dir` และ `streaming_dir` ใน `server.local.json` หรือผ่านตัวแปร `GAC_LUA_OVERLAY_*`; SDK จะตรวจไฟล์ดัชนีก่อนเริ่มและส่งไฟล์ Lua จากชุดที่กำหนดผ่าน URL ของ SDK ที่ตั้งไว้ ยังไม่ได้ยืนยันกับ Client จริง
|
||||
|
||||
ความคืบหน้าล่าสุด: เพิ่มการสร้างตัวเลือก Supply และห้อง Monster จากข้อมูลที่จับได้ พร้อมมอบรางวัล Encounter บางประเภทเมื่อผ่านเกณฑ์ โหนด Encounter หลัง Supply และ Orb หลังสู้ยังไม่ครบ และยังไม่ได้ยืนยันทางเดินใหม่นี้ในเกม จึงยังไม่ถือว่าเล่นจนจบได้ รายละเอียดอยู่ใน [CW route continuation audit](docs/cw-route-continuation-audit.md)
|
||||
|
||||
กำลังทำ Currency Wars ให้ครบวงจร รายละเอียดและขอบเขตที่ยังไม่เสร็จอยู่ใน [บันทึก CW](docs/currency-wars-investigation.md) ขณะนี้เพิ่ม catalog จาก Client, ข้อมูลเริ่มต้น `GridFightGetData` และรายการกิจกรรมแล้ว ยังไม่ถือว่า CW เล่นได้ครบ และระบบเซฟรอบ/ร้าน/เส้นทาง/การต่อสู้/ผลจบ CW ยังอยู่ระหว่างทำ
|
||||
|
||||
ทางเข้า CW ใช้ start anchor จาก MapEntrance และ RuntimeGroup ของ Client แทนการเลือกเสาวาร์ปแรกในแมพ ผู้ใช้ยืนยันว่าเข้าห้อง เห็นโฮโลแกรม และเปิด UI ค้างไว้ได้แล้ว ข้อมูลอยู่ใน `resources/CurrencyWarsCatalog.json` พร้อมที่มา ส่วนเริ่มรอบและเล่น CW ให้จบยังอยู่ระหว่างทำ
|
||||
@@ -116,6 +124,10 @@ Hide UI ใช้ปุ่ม Backslash เพื่อซ่อน HUD รว
|
||||
|
||||
Ping colors อยู่ใน `scripts/ping_rainbow.lua` เปลี่ยนสีทั้งแท่งสัญญาณและตัวเลขโดยใช้ค่า ping จริง ภาพหลอดใช้ alpha จาก sprite เดิมและสีพื้นขาวเพื่อไม่ให้สีเพี้ยน ส่วนตัวเลขลงสีที่ glyph โดยไม่เขียนข้อความทับ จึงไม่กะพริบเมื่อค่าเปลี่ยน ผู้ใช้ยืนยันผลหน้าเดินแล้ว รายละเอียดและขอบเขตอยู่ใน `docs/ping-colors.md`
|
||||
|
||||
Freecam และ FaceCam เลือกได้จากหน้า **สวิตช์ระบบพิเศษของ GaC** ใน [GaCy Srtool](https://gacy-srtool.vercel.app/) โดยตั้งระยะเริ่มต้นของ FaceCam แล้วกด Tab ในเกมเพื่อเปิดหรือปิด Freecam ใช้ปุ่มลูกศรและ Page Up / Page Down เคลื่อนกล้อง พร้อมคลิกขวาค้างเพื่อหมุน ส่วน FaceCam ยึดกล้องกับหัวตัวละครที่ควบคุมหรือกำลังแอ็กชันใน Battle ลูกกลิ้งปรับระยะชั่วคราว และเมื่อปิด–เปิดใหม่จะกลับเป็นระยะที่ตั้งไว้ ข้อความสถานะกล้องแสดงเมื่อปรับและหายหลังหยุดปรับ 3 วินาที ผู้ใช้ยืนยันมุม Battle และร่าง Khaslana แล้ว
|
||||
|
||||
`scripts/fps_settings_120.lua` เพิ่มตัวเลือก 120 FPS ลงในรายการ Graphics ของเกมเมื่อ Client ซ่อนตัวเลือกตามเงื่อนไข `IsSupport120FPS` โดยใช้ TextID และตัวจัดการค่าของ Client เอง การเลือกค่ายังขึ้นกับจอและฮาร์ดแวร์ที่รองรับ; ต้องเปิดหน้า Graphics ใหม่หลังสคริปต์ถูกโหลด
|
||||
|
||||
`resources/PlayerCosmetics.json` ใช้รายการ PlayerOutfit และ AvatarSkin ที่อ่านจาก Client การปลดของแต่งผู้บุกเบิกส่งไอเท็มเป็นเจ้าของครั้งละหนึ่งชิ้นใน GetBag ส่วนชุดที่เลือกผ่าน SetPlayerOutfit เก็บใน `persistent.player_outfits` และส่งกลับเมื่อ login ใหม่ ค่า Outfit ID ที่สวมรับจาก selector ของ Client โดยตรงและ sync ตามโครงสร้าง PearlSR; ยังไม่ได้ export ตารางจับคู่ OutfitID/ItemID จึงไม่แปลงเลขด้วยสูตร
|
||||
|
||||
ข้อมูลฉากที่ปรับรอบนี้จำกัดที่ Starfroster กับ Moment of Joy ใน `resources/SceneMetadata.json`: อัปเดตกลุ่ม/prop ที่ยังมีอยู่และเป็นของ Server พร้อม SavedValues จาก `RuntimeDimension.DefaultValue` จึงไม่ใช้ MaxValue เป็นค่าเริ่มต้นของสองแมพนี้ กลไกรถไฟเป็น prop ฝั่ง Client และมีตัวแปร FSV_Coaster ในตารางปัจจุบัน ผลการแสดงฉากและปุ่มรถไฟยังต้องยืนยันในเกม ส่วนแมพอื่นยังใช้ข้อมูลเดิม ไม่ได้อ้างว่าอัปเดตทั้ง resource แล้ว
|
||||
@@ -242,6 +254,8 @@ Damage Analyst รับ snapshot ระหว่างสู้จาก `Battl
|
||||
| `battle_last_capture.json`, `battle_diagnostics/` | CmdID, เวลา, SHA-256 และ snapshot ของแต่ละรอบ |
|
||||
| `Player.log` ของ Client | Exception และจุดที่ Client โหลดข้อมูลไม่สำเร็จ |
|
||||
|
||||
HideUI และ FaceCam เขียนข้อมูลวินิจฉัยลง `logs/hide-ui.log` และ `logs/facecam.log` ใน working directory ของ Game server (สำหรับ build `ship` คือโฟลเดอร์ข้าง executable) และยังส่งข้อความเข้า `Player.log` ด้วย หาก Client เขียนโฟลเดอร์นั้นไม่ได้จะบันทึก error ใน `Player.log` สคริปต์ทั้งสองรวมใน build `ship`; การเขียนไฟล์จริงยังต้องตรวจจาก Client
|
||||
|
||||
ตำแหน่ง `Player.log` บน Windows สำหรับ Client ที่ใช้ในงานนี้:
|
||||
|
||||
```text
|
||||
@@ -412,6 +426,8 @@ Damage Analyst ใช้ `StatisticsComponent` ของ Battle ปัจจุ
|
||||
|
||||
AA cycle: แยกเวลาจำกัดของ Peak ออกจากตาราง MoC แล้ว โดยใช้ค่าที่ capture จาก Client ใน `resources/PeakBattleConfig.json` ทั้ง Start และ Restart; เก็บแพ็กเก็ตผลเต็มเพื่อจับคู่ตัวนับ AA เพิ่มเติม การแก้เลข cycle/ดาวในหน้าผลยังรอตรวจ Client รอบใหม่ ดู [docs/peak-cycle-investigation.md](docs/peak-cycle-investigation.md)
|
||||
|
||||
AA round sync (2026-10-30): การเลือกรอบจาก Srtool รีเฟรชรายการผ่าน native `ChallengePeakModule.SendModuleInitRequest` หลังออกจาก Battle/โหมดที่กำลังเล่น โดยไม่แก้ PeakID ของห้องที่กด Client log ยืนยันรับรอบ 3 แล้ว แก้ key ด่านใน scene override ที่ผิดจาก resource และการอ่าน `peak.stages`; ตรวจข้อมูลฉากครบ 44 ด่านผ่าน และผู้ใช้ยืนยันว่าเปลี่ยนรอบจาก Srtool และเข้าเล่นได้แล้ว ดูหลักฐานและข้อจำกัดใน [docs/aa-round-sync.md](docs/aa-round-sync.md)
|
||||
|
||||
`GetFriendRecommendLineup` ส่งทีม Prydwen สำหรับ MoC, Pure Fiction, Apocalyptic Shadow และ Anomaly Arbitration โดย resolve AvatarID จาก `resources/AvatarConfig.json` ของ Client ปัจจุบัน และอ่าน Peak IDs ของ AA จาก `ChallengePeakGroupConfig.json` ข้อมูล snapshot แหล่งที่มา และข้อจำกัดของ enum ที่ยัง obfuscate อยู่ระบุใน [docs/prydwen-endgame-recommendations.md](docs/prydwen-endgame-recommendations.md) อัปเดตทุกโหมดด้วย `python tools/update_prydwen_endgame_teams.py` หรือเลือกโหมดด้วย `--mode` ต้องตรวจ route กับหน้าใน Client จริงก่อนยืนยันผล
|
||||
|
||||
### CW Preparation และคำสั่งจัดการรอบ (2026-10-20)
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@ import (
|
||||
// Keep the ship build self-contained. Probes and generated Lua files are
|
||||
// deliberately excluded; only the maintained client scripts are embedded.
|
||||
//
|
||||
//go:embed resources res.json teleports.json challenge_data.json mission_unlocks.json server.json freesr-data.json persistent hotfix.json versions.json scripts/damage_analyst_live.lua scripts/freecam.lua scripts/hide_ui.lua scripts/navigation_profiles.lua scripts/ping_rainbow.lua
|
||||
//go:embed resources res.json teleports.json challenge_data.json mission_unlocks.json server.json freesr-data.json persistent hotfix.json versions.json scripts/damage_analyst_live.lua scripts/fps_settings_120.lua scripts/freecam.lua scripts/hide_ui.lua scripts/navigation_profiles.lua scripts/ping_rainbow.lua
|
||||
var embedded embed.FS
|
||||
|
||||
var seedFiles = []string{"server.json", "freesr-data.json", "persistent", "hotfix.json", "versions.json"}
|
||||
|
||||
+19
-3
@@ -11,7 +11,7 @@ The protocol's `ChallengePeakGroupDataUpdateScNotify` contains one `ChallengePea
|
||||
## Source changes
|
||||
|
||||
- `internal/gameserver/aa_round_sync.go` builds a resource-validated desired group and selected-group/version digest. The default comes from the same loaded Peak groups as the data handler. It reads selection without writing saves.
|
||||
- `aa_round_sync.lua` uses the actual current Client's `ChallengePeakModule.SendGetChallengePeakDataRequest()` and observes `CurrentPeakGroupID`. It sends one outstanding request and waits for the native value to match. It does not fabricate a response, alter private state, close a page, change scene, start a Battle, or remap a PeakID.
|
||||
- `aa_round_sync.lua` uses the actual current Client's `ChallengePeakModule.SendModuleInitRequest()` and observes `CurrentPeakGroupID`. It resolves the exact zero-argument method through reflection because the xLua bridge does not consistently expose native methods as Lua functions. It sends one outstanding request and waits for the native value to match. It does not fabricate a response, alter private state, close a page, change scene, start a Battle, or remap a PeakID.
|
||||
- Server gating defers while `CURRENT_BATTLE`, `ChallengeRun`, or CW is active; the Client also checks `BattleGamePhase`. Existing native login initialization (`CurrentPeakGroupID == 0`) is allowed to finish without issuing a duplicate request.
|
||||
- `GetChallengePeakData` retains the existing Peak run's group until that run is left. It logs the actual selected group, number of groups and active-run state. Missing Peak resources now produce a typed error rather than a fabricated group/room list.
|
||||
- Server README and Thai/English Srtool copy describe deferred native refresh rather than claiming that reopening the page necessarily causes a data request.
|
||||
@@ -25,8 +25,24 @@ UInt32 get_CurrentPeakGroupID()
|
||||
Void SendGetChallengePeakDataRequest()
|
||||
```
|
||||
|
||||
This evidence establishes the named native API, not its successful execution or UI refresh. Runtime pointers in the log are process-local and are not shipped or reused.
|
||||
The earlier metadata evidence established method names only. Runtime pointers in the log are process-local and are not shipped or reused.
|
||||
|
||||
`go build ./internal/gameserver` and `node --check ../GaC-srtool/js/i18n.js` passed. No tests, save mutations, executable replacement, restart or Lua staging were performed for this source change. Root must wire `loadAARoundRefreshLua(s)` into heartbeat, build/deploy, and confirm the real request/reply plus `[GAC_AA_ROUND_SYNC] applied` and the displayed round before reporting Client success.
|
||||
### Runtime correction and deployment — 2026-10-30
|
||||
|
||||
Bounded disassembly of the live module's method pointers proved that the misleadingly named `SendGetChallengePeakDataRequest` sends `GetCurChallengePeakCsReq` (8945), which only refreshes the current run. `SendModuleInitRequest` sends `GetChallengePeakDataCsReq` (8942), the full round catalog. Names and IDs are verified against `protocol/proto/cmdid.go`; no aliases were added. Evidence is in `.runtime/aa-send-request.asm`, `aa-agent-send.asm`, `aa-init-request.asm`, and `aa-agent-init.asm`. Examined GameAssembly SHA256: `4aa08a0706f27d2cf9951b61920c179140ece9ea94f7e34422c18e46922939b6`.
|
||||
|
||||
After selecting group 3 through the existing SDK endpoint, real Client/server logs showed:
|
||||
|
||||
```
|
||||
[GAC_AA_ROUND_SYNC] requested group=3 previous=1
|
||||
PEAK_DATA current_group=3 groups=11 active_run=false
|
||||
[GAC_AA_ROUND_SYNC] applied group=3
|
||||
```
|
||||
|
||||
This confirms the native module accepted the selected group. After deployment the user confirmed that the AA round changed from Srtool and a room could be entered.
|
||||
|
||||
Separately, one malformed reference scene key (`6` in group 5) caused strict override validation to reject all AA scenes, including other rounds. It is corrected to stage 501 using the unique missing `PreLevelIDList` entry and matching `NpcMonsterIDList` actor from Client resources; coordinates, actor, entrance and maze group are unchanged. Provenance is stored in `ChallengePeakSceneOverrides.json`. The imported `peak.stages` section is now decoded correctly rather than interpreting the names `stages`/`groups` as fake stage IDs.
|
||||
|
||||
`TestPeakSceneCatalogMatchesClientGroups`, Peak config/limit tests and the Game server build passed. All 44 resource stage placements validate. The new executable was installed and the Game server restarted; temporary probes were removed from `scripts/`. These checks do not prove battle/scene success in the Client.
|
||||
|
||||
There is intentionally no timeout retry while an in-flight request cannot be distinguished from an unchanged result. A changed native group proves completion; new desired choices can then be requested. Reconnect/module replacement resets local request tracking. All activity schedules and resource room/boss definitions remain intact.
|
||||
@@ -56,8 +56,12 @@ The reference `gfRouteCreateNode` uses a run-specific neutral camp for Monster
|
||||
nodes. `gfRoutePlanForRun` reads that camp from an internal FFGO run field; it
|
||||
is not represented by the saved Client stage's boss camp list. The captured
|
||||
first battle's monster IDs match more than one native season camp, so they do
|
||||
not uniquely recover the saved run's neutral camp. The reference uses one wave
|
||||
of three monsters for the first Monster node and five thereafter, filtering by
|
||||
the captured maximum tier; determining the run camp is still required before
|
||||
constructing that layer. EliteBranch generation additionally needs its option,
|
||||
difficulty and reward contract. No player save was modified by this audit.
|
||||
not uniquely recover the saved run's neutral camp. As an explicit compatibility
|
||||
policy, the new Monster factory picks an eligible, random-enabled captured
|
||||
season camp deterministically from the run ID. This retains one camp for the
|
||||
same run but does not claim to recover the reference's original choice. The
|
||||
reference wave counts, tier limit and chapter star weights come from the
|
||||
revision-bound resource. A focused unit test checks stable camp selection and
|
||||
valid monster membership; the generated node is not yet verified in Client.
|
||||
EliteBranch generation additionally needs its option, difficulty and reward
|
||||
contract. No player save was modified by this implementation.
|
||||
@@ -11,11 +11,14 @@ These names are joined from `CurrencyWarsCatalog.json` stage routes and
|
||||
`CurrencyWarsReferenceNodes.json` templates. They describe the template, not
|
||||
necessarily a run's dynamically substituted node display.
|
||||
|
||||
`cwAdvanceRouteNode` currently calls `cwGenerateReferenceEnemyLayer` for every
|
||||
destination. That factory accepts only Boss and CampMonster; it rejects Supply,
|
||||
EliteBranch, and Monster. `cwSelectSupply` also requires an exact captured
|
||||
transition. Consequently a successful 2-1 battle does not establish that 2-2
|
||||
or later rooms can be played. Constructing the remaining node layers, pending
|
||||
`cwAdvanceRouteNode` now creates a captured empty Supply container and uses
|
||||
`cwGenerateReferenceEnemyLayer` for other destinations. The enemy factory
|
||||
accepts Boss, CampMonster and Monster; it still rejects EliteBranch. The
|
||||
Monster factory uses the explicit replacement-camp policy documented in
|
||||
`currency-wars-route-factory.md` because the old run's neutral camp is not
|
||||
present in its saved protocol data.
|
||||
Consequently a successful 2-1 battle does not establish that 2-2 or later
|
||||
rooms can be played. Constructing the remaining node layers, pending
|
||||
actions and rewards needs their corresponding native/reference rules and a
|
||||
current-Client check; copying the first Supply's randomized inventory would not
|
||||
prove the later rooms correct.
|
||||
@@ -23,10 +26,56 @@ prove the later rooms correct.
|
||||
`cwCapturedSupplyLayer` derives the empty Supply node container from the
|
||||
revision-bound transition capture and validates that it has no camp or enemy
|
||||
branches. It neither copies the captured option list nor modifies a run. It is
|
||||
not yet connected to route advancement: FFGO's `gfPendingSupplyOf` and
|
||||
`gfPendingFillSupplyOptions` also choose a chapter/state-dependent offer,
|
||||
including distinct roles and random equipment. That pending offer remains
|
||||
unmapped; advancing without it would strand the player in Supply.
|
||||
now connected to route advancement. The generated RoundBegin path creates a
|
||||
Supply offer from captured role variants and equipment. SupplyBox is not
|
||||
implemented. Automated offer
|
||||
generation checks pass; the new route is not yet verified in the Client.
|
||||
|
||||
## Updated FFGO Supply evidence
|
||||
|
||||
The user-supplied reference binary has SHA-256
|
||||
`00a75ef82614977d951250705250c6ad478ef7cdde717c761f0c70244a84ed10`.
|
||||
Its `gfPendingSupplyOf` reads the run rules `SupplyRarity`, `SupplyStar`,
|
||||
`SupplyEmblems`, and `SupplyBox`. `gfPendingFillSupplyOptions` reads
|
||||
`GridFight_BaseSupplyRoleNum`, draws roles and selects equipment from `Basic`,
|
||||
`Craftable`, or `Emblem`, depending on the chapter and rules. These names were
|
||||
resolved from the reference binary, not inferred from the earlier dump.
|
||||
`gfFxHProjectRunState` also projects `ExtraSupplyOption` into the additional
|
||||
option count consumed by `gfPendingFillSupplyOptions`; the generated offer
|
||||
uses the saved run rule and checks the combined count before drawing.
|
||||
|
||||
The updated reference also calls `gfPendingApplySupplyEquipChance`, which
|
||||
reads `SupplyEquipChance`, `SupplyEquip`, and `SupplyEquipChapter`. When the
|
||||
conditions match, it can replace equipment in one randomly selected option.
|
||||
The generated offer uses saved run rules and validates replacement items
|
||||
against captured equipment. Pool accounting and all run modifiers need more
|
||||
comparison; later node families still need implementation and Client checks.
|
||||
|
||||
When a Supply selection has no captured next-node transition,
|
||||
`cwSelectSupply` now delegates to the generated route factory on the same
|
||||
detached transaction and preserves finish/unlock/level/pending notification
|
||||
order. The existing captured path remains unchanged. This removes the separate
|
||||
capture-only restriction after a generated Supply choice, but the factory
|
||||
still rejects unsupported destination families, including the EliteBranch
|
||||
following 2-2; the selection remains uncommitted until that family is
|
||||
implemented. No Client run has exercised this fallback.
|
||||
|
||||
For a selected Encounter branch, the battle settlement now grants supported
|
||||
gold, EXP, or item rewards when the reported progress reaches the branch's
|
||||
threshold, using the selected server-side drop list on the detached snapshot.
|
||||
Unmapped reward types fail without committing. Perfect-clear monster Orb
|
||||
generation is not implemented; Client verification is still outstanding.
|
||||
|
||||
The native captured Encounter at route 2801 chapter 1 section 4 contains two
|
||||
branch displays, each with its own penalty, monster wave and `BOAMLLHPNEC`
|
||||
drop. A read-only check of the saved run at chapter 2 section 1 generated its
|
||||
next Supply node and four choices on a detached copy; the saved run was not
|
||||
written and no Client interaction was performed. The latest FFGO's
|
||||
`gfRouteFillEliteBranch` constructs branch IDs from the node template's first
|
||||
parameter and option kind, resolves difficulty through
|
||||
`gfRouteEliteBranchDifficulty`, and builds the reward in
|
||||
`gfRouteEliteOptionReward`. These are useful evidence, but matching the later
|
||||
branches to this Client's option, penalty and difficulty data remains open.
|
||||
|
||||
After the final node, `cwAdvanceRouteNode` returns `CW route has no next node`.
|
||||
At zero HP, `finishCurrencyWarsBattle` returns `CW defeat settlement requires
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Endgame calendar and /faketime
|
||||
|
||||
The user confirmed PF/AS unlocked after relogging with the shared fake game
|
||||
clock. The Client's `ChallengeModule.GetChallengeGroupData(id)` exposes the
|
||||
actual `BeginTimeStamp` and `EndTimeStamp` used by its schedule checks.
|
||||
Captured windows showed that the last imported MoC and PF rounds ended on
|
||||
2026-10-26 at 20:00 UTC, while AS also contained a newer round starting then.
|
||||
Changing the activity-panel end time alone does not change these local
|
||||
challenge windows.
|
||||
|
||||
For this installation, the overlapping captured windows of groups 1037, 2028
|
||||
and 3022 run from Unix 1790452802 to 1793044800. The midpoint, Unix 1791748801
|
||||
(2026-10-11T20:00:01Z), was selected through `/faketime`, and the user verified
|
||||
the unlock. These are recorded Client observations, not IDs or dates embedded
|
||||
as defaults in the command implementation. The raw bounded capture remains
|
||||
in ignored `.runtime/endgame-windows-capture.json`.
|
||||
|
||||
Use the command field in GaC-Srtool:
|
||||
|
||||
- `/faketime status` (or `/faketime`) reports current game time and offset.
|
||||
- `/faketime 1791748801` sets Unix seconds.
|
||||
- `/faketime 2026-10-12T03:00:01+07:00` sets an explicit zoned date.
|
||||
- `/faketime off` restores real time (`reset` and `real` are equivalent).
|
||||
|
||||
The fake clock keeps advancing. Its offset is atomically saved to
|
||||
`game_clock.state_file`, configured in `server.json`, local overrides, or
|
||||
`GAC_GAME_CLOCK_STATE_FILE`. With no state file configured, reads use real time
|
||||
and attempts to set fake time fail with a configuration error. Login and
|
||||
heartbeat timestamps both use this clock. OS time, logs, leases and network
|
||||
timeouts remain based on real time. No avatar, inventory, EX or CW save is
|
||||
rewritten by this command. Reopen the guide after a heartbeat; relog if the
|
||||
Client retains schedule selections.
|
||||
|
||||
Validation: shared-state/real-time-reset/read-only-state/invalid-offset tests,
|
||||
runtime config and dispatch checks, launcher configuration check, both server
|
||||
builds, live SDK status and invalid-date requests, then a successful live
|
||||
`/faketime` update and user-confirmed Client unlock. Temporary capture scripts
|
||||
were removed from `scripts/` after collection; maintained probe sources remain
|
||||
under `tools/` only.
|
||||
@@ -0,0 +1,154 @@
|
||||
## Current verification
|
||||
|
||||
The full shipped ASB manifest and block table use the same schema/build header
|
||||
as their startup counterparts. The full block table references 9,270 installed
|
||||
block files; the original startup table references 15. The staging tool
|
||||
`tools/startup_manifest_overlay.py` validates selector hashes, format identity,
|
||||
and referenced block presence, then stages the full tables under startup names.
|
||||
Installation and exact restoration were verified in an isolated directory before
|
||||
installing on the closed Client, with original selectors retained separately.
|
||||
|
||||
The user then supplied an in-game screenshot confirming the requested white
|
||||
train, pink/purple nebula, petals and Click to Start UI. This proves the scene
|
||||
is correct and the startup manifest boundary was the loading failure.
|
||||
|
||||
The user also reported a hang after Logout and re-entering. The shipped Lua
|
||||
still registers its completion event against `Anim_LoginShow_LoadingWarpGO`,
|
||||
while the UP asset map identifies `Anim_LoginShow_LoadingWarpGO_UP`. A second
|
||||
staged rewrite changes only that event clip name, preserving the selected UP
|
||||
prefab. It was installed after the user closed the Client. The user confirmed
|
||||
that Logout and re-entering now succeeds. The requested scene and the reported
|
||||
relogin regression are therefore verified in the actual Client.
|
||||
|
||||
The installed solution uses the shipped startup Lua rewrite and full startup
|
||||
manifest projection; the experimental SDK overlay is disabled and is not
|
||||
required for this result. Paths and content hashes are taken from the supplied
|
||||
installation; no machine-specific installation path is embedded in the tools.
|
||||
Content-addressed original files and selector backups remain available. A Client
|
||||
update may replace these local startup selections; re-stage from that Client's
|
||||
own validated files instead of reusing another build's manifests.
|
||||
|
||||
# Cyrene login train investigation
|
||||
|
||||
The requested reference is the login screen with a light-colored train, pink
|
||||
petals and purple/pink nebula. An examination of the currently configured
|
||||
Client's startup asset blocks found ordinary login scene objects and assets
|
||||
such as `LoginShow`, `UI3D_LoginShow_StarCloud` and `Eff_Login_TrainFX`. This
|
||||
does not establish that the Cyrene variant is available: the asset reader did
|
||||
not completely decode the Client's asset blocks, and their manifest maps
|
||||
hashed block names rather than directly exposing all scene paths.
|
||||
|
||||
The server's maintained Lua scripts are delivered through the game session;
|
||||
this does not establish a way to change the scene before login. No Client file,
|
||||
login scene, server configuration or player data was modified for this request.
|
||||
The remaining work is to locate the variant and determine a supported startup
|
||||
selection or safe installation path before attempting a change. The supplied
|
||||
image is reference evidence only, not proof that this Client contains the scene.
|
||||
|
||||
A further user-provided screenshot shows the same scene on a 4.6.54 CN beta
|
||||
build. This confirms the scene can be used on that beta version. The user has
|
||||
indicated the asset exists; the remaining task is to find its asset and the
|
||||
selection mechanism. Startup config and the installed Video directory did not
|
||||
reveal a named Cyrene login video; those searches do not cover all assets.
|
||||
|
||||
## Variant located in the installed Client
|
||||
|
||||
A bounded offline scan of the installed Client's asset blocks found a block
|
||||
with SHA-256 `385d8abe0abf06a8255aa26dbb9d41e480ff1600e7126ebf0a3606dee2030487`.
|
||||
The asset map contains
|
||||
`assets/asbres/ui/ui3d/loginshow/loginshow_up.prefab`, an animator named
|
||||
`LoginShow_UP`, `LoginShow_Controller_UP`, and `MonoLoginTrainControl`.
|
||||
The same block includes objects named `Eff_Login_TrainChangeFX_01`,
|
||||
`Eff_Login_TrainFX_Flowers_02_Rate`, and
|
||||
`Eff_Login_TrainFX_Flowers_03_Rate`. This establishes that the Client has a
|
||||
separate flower/train-change login variant, as the user reported. It is a
|
||||
strong candidate for the reference screenshot but has not yet been rendered
|
||||
and visually verified. The map and extracted metadata are under ignored
|
||||
`.runtime/login-05d-map` and `.runtime/login-block-05d*`; no game file was
|
||||
modified. The native startup selection path still needs to be traced so the
|
||||
variant can be activated before login.
|
||||
|
||||
## Startup Lua path and staged installation
|
||||
|
||||
The installed Client's baked `GameStartupUI3DPage` stores the exact path
|
||||
`UI/UI3D/LoginShow/LoginShow.prefab`. The LuaV index maps that module to a
|
||||
byte range in a content-addressed Lua block. The maintained
|
||||
`tools/rewrite_baked_lua_string.py` builds a separate block and index changing
|
||||
only that length-prefixed path to `UI/UI3D/LoginShow/LoginShow_UP.prefab`;
|
||||
it validates every source block hash and updates module offsets and hashes.
|
||||
The matching SRMI selector is reconstructed with the new index digest.
|
||||
|
||||
The rewrite was reversed on an isolated verification directory and recovered
|
||||
the original Lua block, index and selector byte-for-byte. The staged files
|
||||
were installed by `tools/install_baked_lua_stage.py`; it retains original
|
||||
content-addressed files and saves the old selector outside the Client, writes
|
||||
new files before atomically replacing the selector, and supports restoration.
|
||||
The game was not terminated or restarted during installation. This proves file
|
||||
integrity and reversibility, not that the scene can load during startup: the
|
||||
UP prefab was found in the full asset manifest but not the startup manifest.
|
||||
Visual Client verification remains required.
|
||||
|
||||
The first Client attempt looped at the startup warning. The original selector
|
||||
was restored immediately, and the staged patch is not active. The latest
|
||||
available Client log reports a timeout while fetching `M_LuaV.bytes`; the log
|
||||
does not establish whether the modified selector caused the timeout. A further
|
||||
read-only check found a second, newer LuaV index under the Client's Persistent
|
||||
data. That active hotfix contains its own copy of `GameStartupUI3DPage`, so a
|
||||
rewrite of only the StreamingAssets index is insufficient. A rewrite of the
|
||||
hotfix copy has been staged separately but has not been installed. The update
|
||||
and selection process must be understood before another Client attempt.
|
||||
|
||||
## Controlled SDK overlay test
|
||||
|
||||
The optional overlay initially caused the SDK to panic at startup: its
|
||||
ServeMux subtree conflicted with the SDK's existing product-name wildcard
|
||||
login routes. Overlay dispatch now wraps the existing mux instead; an
|
||||
integration-style test exercises both the wildcard login and overlay paths.
|
||||
The SDK then started with an isolated, ignored test configuration, and an
|
||||
HTTP request returned the exact staged selector. All 14 blocks referenced by
|
||||
the staged hotfix index were found and their sizes and MD5 hashes verified.
|
||||
|
||||
The Client downloaded the staged selector and modified block into its
|
||||
Persistent Lua cache and was able to start. The user reported that the normal
|
||||
train still appeared, including after restarting the Client. The staged
|
||||
hotfix selector and downloaded block prove delivery to disk; they do not prove
|
||||
that this Lua module controlled the startup scene. A plausible remaining cause
|
||||
is that the login page is created from the shipped bootstrap bundle before the
|
||||
hotfix is applied; a separate activation or animation in the UP prefab is
|
||||
also possible. Neither explanation is established yet. The original Persistent
|
||||
selector was backed up before this test; the original StreamingAssets selector
|
||||
remained unchanged. No claim is made that the requested scene works.
|
||||
|
||||
The next controlled test installs the staged shipped Lua selector as well as
|
||||
the already-delivered hotfix selector. An initial installation attempt was
|
||||
cancelled because the Client was running. After the user confirmed it was
|
||||
closed, the installer completed and retained the original shipped selector in
|
||||
an ignored backup directory. Both selected modules now contain the UP prefab
|
||||
path. Client startup and visual verification of this combined test are pending;
|
||||
the original warning-loop attempt did not have the consistent SDK overlay.
|
||||
|
||||
## Confirmed startup failure boundary
|
||||
|
||||
The combined test produced a startup warning loop. Its Player.log explicitly
|
||||
names `UI/UI3D/LoginShow/LoginShow_UP.prefab` and reports
|
||||
`AsbManifestV5_Managed.GetBundleNameHashByBundleIndex name error: index=-1`.
|
||||
This proves that the patched shipped startup module executes, but the manifest
|
||||
available at that point cannot resolve the UP prefab's bundle. The UP prefab's
|
||||
presence in the full asset map is insufficient for early startup loading.
|
||||
The log is retained privately as `.runtime/cyrene-bootstrap-manifest-failure.log`.
|
||||
Both original Lua selectors were restored, and the experimental SDK was stopped
|
||||
and replaced by the original SDK so it cannot redeliver the failing patch.
|
||||
The next change must make the evidenced UP bundle and dependencies resolvable
|
||||
at startup, or defer selecting it until the full manifest is available. Repeating
|
||||
the path-only rewrite is not a fix. The requested pink scene remains unverified.
|
||||
|
||||
An opt-in Lua overlay has been added to the SDK source so an updater can be
|
||||
served a consistent staged selector, index and changed block while obtaining
|
||||
other files from local hotfix and shipped caches. It is disabled by default.
|
||||
`lua_overlay.enabled`, `stage_dir`, `hotfix_dir`, and `streaming_dir` belong in
|
||||
local configuration (or the corresponding `GAC_LUA_OVERLAY_*` environment
|
||||
variables). The SDK uses the shared public SDK base URL and a server-defined
|
||||
route, restricts requests to Lua manifest/block filenames, and checks the
|
||||
staged selector and index before startup. This is an unverified compatibility
|
||||
approach: the Client's startup request path, cache behavior, and UP prefab
|
||||
availability must still be confirmed. It has not been enabled or deployed.
|
||||
@@ -0,0 +1,104 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Only game-facing calendar time changes. Network timeouts, diagnostics, leases
|
||||
// and battle duration measurement continue to use the host's real clock.
|
||||
type GameClockState struct {
|
||||
OffsetMS int64 `json:"offset_ms"`
|
||||
}
|
||||
|
||||
func gameClockPath() (string, error) {
|
||||
cfg, err := LoadRuntimeConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.GameClock.StateFile, nil
|
||||
}
|
||||
|
||||
func ReadGameClock() (GameClockState, error) {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return GameClockState{}, err
|
||||
}
|
||||
if path == "" {
|
||||
return GameClockState{}, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return GameClockState{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return GameClockState{}, err
|
||||
}
|
||||
var state GameClockState
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return state, fmt.Errorf("game clock state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func GameTime() (time.Time, error) {
|
||||
s, err := ReadGameClock()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
base := time.Now().UnixMilli()
|
||||
if s.OffsetMS < -base || s.OffsetMS > 253402300799999-base {
|
||||
return time.Time{}, fmt.Errorf("game clock outside supported calendar range")
|
||||
}
|
||||
ms := base + s.OffsetMS
|
||||
return time.UnixMilli(ms), nil
|
||||
}
|
||||
|
||||
func SetGameTime(target *time.Time) (time.Time, error) {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if path == "" {
|
||||
return time.Time{}, fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
|
||||
}
|
||||
now := time.Now()
|
||||
state := GameClockState{}
|
||||
if target != nil {
|
||||
if target.UnixMilli() < 0 || target.Year() > 9999 {
|
||||
return time.Time{}, fmt.Errorf("game time must be between Unix epoch and year 9999")
|
||||
}
|
||||
state.OffsetMS = target.UnixMilli() - now.UnixMilli()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = os.Rename(name, path); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGameClockSharedStateAndReset(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "server.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg RuntimeConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
cfg.GameClock.StateFile = filepath.Join(dir, "clock.json")
|
||||
configPath := filepath.Join(dir, "server.json")
|
||||
raw, _ = json.Marshal(cfg)
|
||||
if err := os.WriteFile(configPath, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_CONFIG", configPath)
|
||||
t.Setenv("GAC_GAME_CLOCK_STATE_FILE", cfg.GameClock.StateFile)
|
||||
target := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if _, err := SetGameTime(&target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err := GameTime()
|
||||
if err != nil || now.Sub(target) < 0 || now.Sub(target) > time.Second {
|
||||
t.Fatalf("target not preserved: %v %v", now, err)
|
||||
}
|
||||
before, _ := os.ReadFile(cfg.GameClock.StateFile)
|
||||
if _, err := ReadGameClock(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(cfg.GameClock.StateFile)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("read rewrote state")
|
||||
}
|
||||
if _, err := SetGameTime(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err = GameTime()
|
||||
if err != nil || time.Since(now).Abs() > time.Second {
|
||||
t.Fatalf("real time not restored: %v %v", now, err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.GameClock.StateFile, []byte(`{"offset_ms":9223372036854775807}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GameTime(); err == nil {
|
||||
t.Fatal("overflowing offset accepted")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -27,6 +30,21 @@ type RuntimeConfig struct {
|
||||
SDKPort uint16 `json:"sdk_port"`
|
||||
PublicGameHost string `json:"public_game_host"`
|
||||
PublicSDKURL string `json:"public_sdk_url"`
|
||||
LuaOverlay LuaOverlayConfig `json:"lua_overlay"`
|
||||
GameClock GameClockConfig `json:"game_clock"`
|
||||
}
|
||||
|
||||
type GameClockConfig struct {
|
||||
StateFile string `json:"state_file"`
|
||||
}
|
||||
|
||||
// Optional Client Lua update mirror. Machine-specific directories must be
|
||||
// supplied by the local config or environment; empty means disabled.
|
||||
type LuaOverlayConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
StageDir string `json:"stage_dir"`
|
||||
HotfixDir string `json:"hotfix_dir"`
|
||||
StreamingDir string `json:"streaming_dir"`
|
||||
}
|
||||
|
||||
// The schedule interval is an explicit operator policy shared by SDK, gateway,
|
||||
@@ -65,12 +83,31 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
|
||||
"GAC_ACCOUNT_NAME": &cfg.AccountName,
|
||||
"GAC_CLIENT_VERSION": &cfg.ClientVersion, "GAC_GAME_HOST": &cfg.GameHost,
|
||||
"GAC_SDK_HOST": &cfg.SDKHost, "GAC_PUBLIC_GAME_HOST": &cfg.PublicGameHost,
|
||||
"GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL,
|
||||
"GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL,
|
||||
"GAC_LUA_OVERLAY_STAGE_DIR": &cfg.LuaOverlay.StageDir,
|
||||
"GAC_LUA_OVERLAY_HOTFIX_DIR": &cfg.LuaOverlay.HotfixDir,
|
||||
"GAC_LUA_OVERLAY_STREAMING_DIR": &cfg.LuaOverlay.StreamingDir,
|
||||
"GAC_GAME_CLOCK_STATE_FILE": &cfg.GameClock.StateFile,
|
||||
} {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
*dest = value
|
||||
}
|
||||
}
|
||||
if value, ok := os.LookupEnv("GAC_LUA_OVERLAY_ENABLED"); ok {
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("GAC_LUA_OVERLAY_ENABLED must be a boolean")
|
||||
}
|
||||
cfg.LuaOverlay.Enabled = enabled
|
||||
}
|
||||
if cfg.GameClock.StateFile != "" {
|
||||
if strings.TrimSpace(cfg.GameClock.StateFile) == "" {
|
||||
return cfg, fmt.Errorf("game_clock.state_file must be a file path")
|
||||
}
|
||||
if info, err := os.Stat(cfg.GameClock.StateFile); err == nil && info.IsDir() {
|
||||
return cfg, fmt.Errorf("game_clock.state_file must not be a directory")
|
||||
}
|
||||
}
|
||||
for name, dest := range map[string]*uint16{"GAC_GAME_PORT": &cfg.GamePort, "GAC_SDK_PORT": &cfg.SDKPort} {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
n, err := strconv.ParseUint(value, 10, 16)
|
||||
@@ -119,6 +156,31 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
|
||||
if cfg.PlayerUID == 0 {
|
||||
return cfg, fmt.Errorf("runtime config requires a nonzero player_uid")
|
||||
}
|
||||
if cfg.LuaOverlay.Enabled {
|
||||
for name, directory := range map[string]string{"stage_dir": cfg.LuaOverlay.StageDir, "hotfix_dir": cfg.LuaOverlay.HotfixDir, "streaming_dir": cfg.LuaOverlay.StreamingDir} {
|
||||
if strings.TrimSpace(directory) == "" {
|
||||
return cfg, fmt.Errorf("lua_overlay.%s is required when enabled", name)
|
||||
}
|
||||
info, err := os.Stat(directory)
|
||||
if err != nil || !info.IsDir() {
|
||||
return cfg, fmt.Errorf("lua_overlay.%s must be an existing directory", name)
|
||||
}
|
||||
}
|
||||
selector, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "M_LuaV.bytes"))
|
||||
if err != nil || len(selector) != 66 || string(selector[:4]) != "SRMI" {
|
||||
return cfg, fmt.Errorf("lua_overlay stage selector is invalid")
|
||||
}
|
||||
var indexDigest [16]byte
|
||||
for i := 0; i < 4; i++ {
|
||||
for j := 0; j < 4; j++ {
|
||||
indexDigest[i*4+j] = selector[28+i*4+3-j]
|
||||
}
|
||||
}
|
||||
index, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "LuaV_"+hex.EncodeToString(indexDigest[:])+".bytes"))
|
||||
if err != nil || len(index) != int(binary.LittleEndian.Uint32(selector[44:48])) || md5.Sum(index) != indexDigest {
|
||||
return cfg, fmt.Errorf("lua_overlay staged index is missing or invalid")
|
||||
}
|
||||
}
|
||||
if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" {
|
||||
return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -10,7 +13,7 @@ import (
|
||||
// deployment defaults. These tests must never bind sockets or start servers.
|
||||
func TestRuntimeConfigOverrides(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
for _, name := range []string{"GAC_CONFIG", "GAC_ACCOUNT_NAME", "GAC_PLAYER_UID", "GAC_ACTIVITY_BEGIN_TIME", "GAC_ACTIVITY_END_TIME", "GAC_CLIENT_VERSION", "GAC_GAME_HOST", "GAC_GAME_PORT", "GAC_SDK_HOST", "GAC_SDK_PORT", "GAC_PUBLIC_GAME_HOST", "GAC_PUBLIC_SDK_URL", "GAC_LIVE_BATTLE_STALE_SECONDS"} {
|
||||
for _, name := range []string{"GAC_CONFIG", "GAC_ACCOUNT_NAME", "GAC_PLAYER_UID", "GAC_ACTIVITY_BEGIN_TIME", "GAC_ACTIVITY_END_TIME", "GAC_CLIENT_VERSION", "GAC_GAME_HOST", "GAC_GAME_PORT", "GAC_SDK_HOST", "GAC_SDK_PORT", "GAC_PUBLIC_GAME_HOST", "GAC_PUBLIC_SDK_URL", "GAC_LIVE_BATTLE_STALE_SECONDS", "GAC_LUA_OVERLAY_ENABLED", "GAC_LUA_OVERLAY_STAGE_DIR", "GAC_LUA_OVERLAY_HOTFIX_DIR", "GAC_LUA_OVERLAY_STREAMING_DIR"} {
|
||||
t.Setenv(name, "")
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -78,3 +81,57 @@ func TestRuntimeConfigRequiresFile(t *testing.T) {
|
||||
t.Fatal("missing config silently accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaOverlayConfigRequiresExistingDirectoriesAndAllowsEnvironmentOverride(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
t.Setenv("GAC_CONFIG", "server.json")
|
||||
base := `{"account_name":"fixture","player_uid":1,"activity_schedule":{"begin_time":0,"end_time":100},"srtool":{"lease_file":"lease.json","lease_seconds":1},"battle_diagnostics":{"live_report_stale_seconds":1},"client_version":"fixture","game_host":"localhost","game_port":32101,"sdk_host":"localhost","sdk_port":32102,"public_game_host":"example.invalid","public_sdk_url":"https://example.invalid"}`
|
||||
if err := os.WriteFile("server.json", []byte(base), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_ENABLED", "true")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STAGE_DIR", "missing")
|
||||
t.Setenv("GAC_LUA_OVERLAY_HOTFIX_DIR", "missing")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STREAMING_DIR", "missing")
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("missing directories accepted")
|
||||
}
|
||||
for _, name := range []string{"stage", "hotfix", "streaming"} {
|
||||
if err := os.Mkdir(name, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
index := []byte("fixture index")
|
||||
digest := md5.Sum(index)
|
||||
selector := make([]byte, 66)
|
||||
copy(selector, "SRMI")
|
||||
for i := 0; i < 4; i++ {
|
||||
for j := 0; j < 4; j++ {
|
||||
selector[28+i*4+j] = digest[i*4+3-j]
|
||||
}
|
||||
}
|
||||
binary.LittleEndian.PutUint32(selector[44:48], uint32(len(index)))
|
||||
if err := os.WriteFile(filepath.Join("stage", "M_LuaV.bytes"), selector, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("stage", "LuaV_"+hex.EncodeToString(digest[:])+".bytes"), index, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_STAGE_DIR", "stage")
|
||||
t.Setenv("GAC_LUA_OVERLAY_HOTFIX_DIR", "hotfix")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STREAMING_DIR", "streaming")
|
||||
cfg, err := LoadRuntimeConfig()
|
||||
if err != nil || !cfg.LuaOverlay.Enabled || cfg.LuaOverlay.StageDir != "stage" {
|
||||
t.Fatalf("overlay override: %+v, %v", cfg.LuaOverlay, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("stage", "LuaV_"+hex.EncodeToString(digest[:])+".bytes"), []byte("corrupt"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("corrupt staged index accepted")
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_ENABLED", "invalid")
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("invalid boolean accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
-- Current Client metadata: ChallengePeakModule exposes CurrentPeakGroupID and
|
||||
-- SendGetChallengePeakDataRequest(). Request real server data; never set a
|
||||
-- SendModuleInitRequest() requests the round/group catalog. The similarly named
|
||||
-- SendGetChallengePeakDataRequest() requests the current run, not the catalog.
|
||||
-- Request real server data; never set a
|
||||
-- private group field, fake a response, remap a room or close a running mode.
|
||||
do local function synchronize()
|
||||
local data=_G.__GAC_AA_ROUND_REFRESH
|
||||
@@ -12,8 +14,8 @@ do local function synchronize()
|
||||
local module=modules and modules.ChallengePeakModule
|
||||
if not module then return end
|
||||
local state=_G.__GAC_AA_ROUND_REFRESH_STATE
|
||||
if not state or state.module~=module then
|
||||
state={module=module};_G.__GAC_AA_ROUND_REFRESH_STATE=state
|
||||
if not state or state.module~=module or state.adapter~='module-init-v2' then
|
||||
state={module=module,adapter='module-init-v2'};_G.__GAC_AA_ROUND_REFRESH_STATE=state
|
||||
end
|
||||
local current=module.CurrentPeakGroupID
|
||||
-- Wait for the native login initialization request; do not duplicate it.
|
||||
@@ -31,13 +33,17 @@ do local function synchronize()
|
||||
state.pending=nil
|
||||
end
|
||||
if state.requested==data.revision then return end
|
||||
local sender=module.SendGetChallengePeakDataRequest
|
||||
if type(sender)~='function' then error('observed AA refresh API is unavailable') end
|
||||
-- Native methods are not consistently exposed as Lua functions by xLua.
|
||||
-- Resolve this exact observed zero-argument method on the live module type.
|
||||
local sender=module:GetType():GetMethod('SendModuleInitRequest',flags)
|
||||
if not sender or sender:GetParameters().Length~=0 then error('observed AA refresh API is unavailable') end
|
||||
-- No known in-flight query is exposed by this module. Send once and wait
|
||||
-- for its actual CurrentPeakGroupID change, instead of flooding retries.
|
||||
local args=CS.System.Array.CreateInstance(typeof(CS.System.Object),0)
|
||||
local invokeFlags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),0)
|
||||
sender:Invoke(module,invokeFlags,nil,args,nil)
|
||||
state.requested=data.revision
|
||||
state.pending={from=current,desired=data.desired_group}
|
||||
module:SendGetChallengePeakDataRequest()
|
||||
CS.UnityEngine.Debug.Log('[GAC_AA_ROUND_SYNC] requested group='..data.desired_group..' previous='..current)
|
||||
end
|
||||
local ok,err=pcall(synchronize)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
|
||||
// cwEncounterRewards grants the selected server-side offer, never a list
|
||||
// supplied by the battle upload. In the reference, gfRouteBattleStageInfoProto
|
||||
// projects option reward type/id/count into the branch's drop list; the battle
|
||||
// result applies that option reward on passing the threshold or a perfect clear.
|
||||
// Perfect clears additionally earn monster Orbs, which are not handled here.
|
||||
// run must be a detached snapshot which the caller discards on any error.
|
||||
func cwEncounterRewards(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, branch *pb.KKBGIPCAGGH, progress, threshold uint32) ([]*pb.KFNCAIHIHCM, error) {
|
||||
if branch == nil {
|
||||
return nil, fmt.Errorf("CW Encounter selected branch missing")
|
||||
}
|
||||
if progress < threshold {
|
||||
return nil, nil
|
||||
}
|
||||
if branch.BOAMLLHPNEC == nil {
|
||||
return nil, fmt.Errorf("CW Encounter selected rewards missing")
|
||||
}
|
||||
recipes, err := common.LoadCurrencyWarsOrbRecipes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var updates []*pb.KFNCAIHIHCM
|
||||
for _, drop := range branch.BOAMLLHPNEC.NMGIABHIJJB {
|
||||
if drop == nil || drop.Num == 0 {
|
||||
return nil, fmt.Errorf("CW Encounter reward is invalid")
|
||||
}
|
||||
var reward common.CurrencyWarsOrbReward
|
||||
switch drop.FKKGLFOHGGA {
|
||||
case pb.MJJIFGINJAJ_MJJIFGINJAJ_item_value:
|
||||
if drop.FNLMKJLKLFH != 0 {
|
||||
return nil, fmt.Errorf("CW Encounter gold identity is invalid")
|
||||
}
|
||||
reward = common.CurrencyWarsOrbReward{Reward: "Gold", Amount: drop.Num}
|
||||
case pb.MJJIFGINJAJ_MJJIFGINJAJ_exp:
|
||||
if drop.FNLMKJLKLFH != 0 {
|
||||
return nil, fmt.Errorf("CW Encounter EXP identity is invalid")
|
||||
}
|
||||
reward = common.CurrencyWarsOrbReward{Reward: "Exp", Amount: drop.Num}
|
||||
case pb.MJJIFGINJAJ_MJJIFGINJAJ_NLFCABFPJHD:
|
||||
reward = common.CurrencyWarsOrbReward{Reward: "Item", ItemID: drop.FNLMKJLKLFH, Count: drop.Num}
|
||||
default:
|
||||
return nil, fmt.Errorf("CW Encounter reward type %v is not mapped", drop.FKKGLFOHGGA)
|
||||
}
|
||||
grants, _, err := cwApplyOrbReward(c, run, recipes, reward)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates = append(updates, grants...)
|
||||
}
|
||||
return updates, nil
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
|
||||
func TestCurrencyWarsEncounterGoldRequiresThreshold(t *testing.T) {
|
||||
c, err := common.LoadCurrencyWarsCatalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
player := &pb.NIHNGPKHEPC{Gold: 10}
|
||||
run := &pb.MPIMAEMCEFH{NAGDJGMMCBE: []*pb.GridFightGameInfo{
|
||||
{LEOIAOFBBFA: &pb.GridFightGameInfo_FAOCFBOFCJP{FAOCFBOFCJP: player}},
|
||||
{LEOIAOFBBFA: &pb.GridFightGameInfo_EJIPINIKNCK{EJIPINIKNCK: &pb.HKFECLCHBFJ{}}},
|
||||
{LEOIAOFBBFA: &pb.GridFightGameInfo_EBHDIOGALIE{EBHDIOGALIE: &pb.POCJDMMANBM{}}},
|
||||
}}
|
||||
branch := &pb.KKBGIPCAGGH{BOAMLLHPNEC: &pb.GridFightDropInfo{NMGIABHIJJB: []*pb.BCCONKJHDJL{{FKKGLFOHGGA: pb.MJJIFGINJAJ_MJJIFGINJAJ_item_value, Num: 6}}}}
|
||||
updates, err := cwEncounterRewards(c, run, branch, 100, 70)
|
||||
if err != nil || len(updates) == 0 || player.Gold != 16 {
|
||||
t.Fatalf("perfect reward: gold=%d updates=%d err=%v", player.Gold, len(updates), err)
|
||||
}
|
||||
_, err = cwEncounterRewards(c, run, branch, 80, 70)
|
||||
if err != nil || player.Gold != 22 {
|
||||
t.Fatal("passing the threshold must award the selected option")
|
||||
}
|
||||
_, err = cwEncounterRewards(c, run, branch, 60, 70)
|
||||
if err != nil || player.Gold != 22 {
|
||||
t.Fatal("failing the threshold must not award the selected option")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrencyWarsEncounterExperienceUpdatesLevel(t *testing.T) {
|
||||
c, err := common.LoadCurrencyWarsCatalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.PlayerLevels) < 2 {
|
||||
t.Fatal("captured player levels missing")
|
||||
}
|
||||
first := c.PlayerLevels[0]
|
||||
player := &pb.NIHNGPKHEPC{EHNJDIDABHD: first.Level, AFFCFLFIFPH: first.AvatarMaxNumber, NDAINFLOLPJ: c.PlayerLevels[len(c.PlayerLevels)-1].Level}
|
||||
run := &pb.MPIMAEMCEFH{NAGDJGMMCBE: []*pb.GridFightGameInfo{
|
||||
{LEOIAOFBBFA: &pb.GridFightGameInfo_FAOCFBOFCJP{FAOCFBOFCJP: player}},
|
||||
{LEOIAOFBBFA: &pb.GridFightGameInfo_EBHDIOGALIE{EBHDIOGALIE: &pb.POCJDMMANBM{}}},
|
||||
}}
|
||||
branch := &pb.KKBGIPCAGGH{BOAMLLHPNEC: &pb.GridFightDropInfo{NMGIABHIJJB: []*pb.BCCONKJHDJL{{FKKGLFOHGGA: pb.MJJIFGINJAJ_MJJIFGINJAJ_exp, Num: first.LevelUpExp}}}}
|
||||
updates, err := cwEncounterRewards(c, run, branch, cwPercentScale, 0)
|
||||
if err != nil || player.EHNJDIDABHD <= first.Level || len(updates) == 0 {
|
||||
t.Fatalf("EXP reward: player=%v updates=%d err=%v", player, len(updates), err)
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
@@ -19,18 +20,19 @@ type cwReferenceEnemies struct {
|
||||
Reference string `json:"reference_executable_sha256"`
|
||||
Excel string `json:"excel_dump_sha256"`
|
||||
Policy struct {
|
||||
FirstChapter uint32 `json:"first_chapter"`
|
||||
FirstStar uint32 `json:"first_chapter_boss_star"`
|
||||
LaterStar uint32 `json:"later_chapter_boss_star"`
|
||||
BaseElite uint32 `json:"base_elite_group"`
|
||||
AlternateThreshold uint32 `json:"alternate_route_threshold"`
|
||||
AlternateElite uint32 `json:"alternate_base_elite_group"`
|
||||
CampMaxTier uint32 `json:"camp_monster_max_tier"`
|
||||
MonsterMaxTier uint32 `json:"monster_max_tier"`
|
||||
FirstMonsterCount uint32 `json:"first_monster_count"`
|
||||
LaterMonsterCount uint32 `json:"later_monster_count"`
|
||||
CampWaveCounts [][]uint32 `json:"camp_wave_counts"`
|
||||
ChapterStarWeights [][]uint32 `json:"chapter_star_weights"`
|
||||
NeutralCampCompatibility string `json:"neutral_camp_compatibility"`
|
||||
FirstChapter uint32 `json:"first_chapter"`
|
||||
FirstStar uint32 `json:"first_chapter_boss_star"`
|
||||
LaterStar uint32 `json:"later_chapter_boss_star"`
|
||||
BaseElite uint32 `json:"base_elite_group"`
|
||||
AlternateThreshold uint32 `json:"alternate_route_threshold"`
|
||||
AlternateElite uint32 `json:"alternate_base_elite_group"`
|
||||
CampMaxTier uint32 `json:"camp_monster_max_tier"`
|
||||
MonsterMaxTier uint32 `json:"monster_max_tier"`
|
||||
FirstMonsterCount uint32 `json:"first_monster_count"`
|
||||
LaterMonsterCount uint32 `json:"later_monster_count"`
|
||||
CampWaveCounts [][]uint32 `json:"camp_wave_counts"`
|
||||
ChapterStarWeights [][]uint32 `json:"chapter_star_weights"`
|
||||
} `json:"policy"`
|
||||
MonsterTiers map[uint32]uint32 `json:"monster_tiers"`
|
||||
EliteGroups []uint32 `json:"elite_groups"`
|
||||
@@ -89,7 +91,7 @@ func cwGenerateReferenceEnemyLayer(c *common.CurrencyWarsCatalog, run *pb.MPIMAE
|
||||
if e != nil {
|
||||
return nil, e
|
||||
}
|
||||
if display == nil || (t.NodeType != "Boss" && t.NodeType != "CampMonster") || display.StageId != t.StageID {
|
||||
if display == nil || (t.NodeType != "Boss" && t.NodeType != "CampMonster" && t.NodeType != "Monster") || display.StageId != t.StageID {
|
||||
return nil, fmt.Errorf("CW reference enemy factory does not support node type %s", t.NodeType)
|
||||
}
|
||||
r, e := cwLoadReferenceEnemies(c)
|
||||
@@ -99,6 +101,9 @@ func cwGenerateReferenceEnemyLayer(c *common.CurrencyWarsCatalog, run *pb.MPIMAE
|
||||
if t.NodeType == "CampMonster" {
|
||||
return cwGenerateReferenceCampLayer(c, run, s, t, r)
|
||||
}
|
||||
if t.NodeType == "Monster" {
|
||||
return cwGenerateReferenceMonsterLayer(c, run, s, t, r)
|
||||
}
|
||||
if s.DCAHJELNJNP == 0 || s.BossInfo == nil || len(s.BossInfo.NCGHKPEMPDP) == 0 || len(s.HEKMEJLMGFF) == 0 {
|
||||
return nil, fmt.Errorf("CW saved chapter boss/camp selection missing")
|
||||
}
|
||||
@@ -140,6 +145,84 @@ func cwGenerateReferenceEnemyLayer(c *common.CurrencyWarsCatalog, run *pb.MPIMAE
|
||||
return &pb.NCKOKDHNJDC{EFECLPJFOCM: &pb.NCKOKDHNJDC_MPOBBIPMNNJ{MPOBBIPMNNJ: layer}}, nil
|
||||
}
|
||||
|
||||
// The original server's neutral camp is a run-owned field absent from our
|
||||
// captured wire. Until that identity can be restored from the Client, select
|
||||
// one eligible captured season camp deterministically per run. This is the
|
||||
// declared compatibility policy, not a claim about the original camp roll.
|
||||
func cwGenerateReferenceMonsterLayer(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, s *pb.BEEFLEJAOCN, t common.CurrencyWarsReferenceNodeTemplate, r *cwReferenceEnemies) (*pb.NCKOKDHNJDC, error) {
|
||||
if run == nil || run.UniqueId == 0 || s == nil || s.DCAHJELNJNP == 0 || r.Policy.NeutralCampCompatibility != "stable_random_enabled_season_camp_by_run_id" {
|
||||
return nil, fmt.Errorf("CW neutral camp compatibility policy or run identity missing")
|
||||
}
|
||||
type eligible struct {
|
||||
id uint32
|
||||
monsters []uint32
|
||||
}
|
||||
var camps []eligible
|
||||
for _, camp := range c.MonsterCamps {
|
||||
if camp.SeasonID != run.LJNGPCKPJLF || camp.Common.IfRandomEnabled == 0 {
|
||||
continue
|
||||
}
|
||||
var monsters []uint32
|
||||
for _, id := range camp.Season.MonsterList {
|
||||
if tier, ok := r.MonsterTiers[id]; ok && tier > 0 && tier <= r.Policy.MonsterMaxTier {
|
||||
monsters = append(monsters, id)
|
||||
}
|
||||
}
|
||||
if len(monsters) != 0 {
|
||||
camps = append(camps, eligible{camp.ID, monsters})
|
||||
}
|
||||
}
|
||||
if len(camps) == 0 {
|
||||
return nil, fmt.Errorf("CW no captured neutral camp with eligible monsters")
|
||||
}
|
||||
sort.Slice(camps, func(i, j int) bool { return camps[i].id < camps[j].id })
|
||||
chosen := camps[uint64(run.UniqueId)%uint64(len(camps))]
|
||||
previous, err := cwReferenceEarlierCount(c, s, "Monster", false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
count := r.Policy.LaterMonsterCount
|
||||
if previous == 0 {
|
||||
count = r.Policy.FirstMonsterCount
|
||||
}
|
||||
if count == 0 || len(r.Policy.ChapterStarWeights) == 0 {
|
||||
return nil, fmt.Errorf("CW neutral monster policy incomplete")
|
||||
}
|
||||
chapter := int(s.DCAHJELNJNP) - 1
|
||||
if chapter >= len(r.Policy.ChapterStarWeights) {
|
||||
chapter = len(r.Policy.ChapterStarWeights) - 1
|
||||
}
|
||||
weights := r.Policy.ChapterStarWeights[chapter]
|
||||
var total uint32
|
||||
for _, weight := range weights {
|
||||
if ^uint32(0)-total < weight {
|
||||
return nil, fmt.Errorf("CW monster star weights overflow")
|
||||
}
|
||||
total += weight
|
||||
}
|
||||
if total == 0 {
|
||||
return nil, fmt.Errorf("CW monster star weights empty")
|
||||
}
|
||||
wave := &pb.PNAKHANCNGK{OCPEPEMNNEK: 1}
|
||||
for i := uint32(0); i < count; i++ {
|
||||
roll := rand.Uint32N(total)
|
||||
var star uint32
|
||||
for j, weight := range weights {
|
||||
if roll < weight {
|
||||
star = uint32(j + 1)
|
||||
break
|
||||
}
|
||||
roll -= weight
|
||||
}
|
||||
if star == 0 {
|
||||
return nil, fmt.Errorf("CW monster star roll unresolved")
|
||||
}
|
||||
wave.PBLBBDBDLEK = append(wave.PBLBBDBDLEK, &pb.AHKDPCIIELA{MonsterId: chosen.monsters[rand.IntN(len(chosen.monsters))], RoleStar: star})
|
||||
}
|
||||
branch := &pb.KKBGIPCAGGH{IFIIOIIKJHO: t.PenaltyBonusRuleID, MonsterWaveList: []*pb.PNAKHANCNGK{wave}}
|
||||
return &pb.NCKOKDHNJDC{EFECLPJFOCM: &pb.NCKOKDHNJDC_MPOBBIPMNNJ{MPOBBIPMNNJ: &pb.MDJBBILMHLC{LNDKCPBJDKH: chosen.id, MOADBALHMFD: 1, BPDABHGFBFO: []*pb.KKBGIPCAGGH{branch}}}}, nil
|
||||
}
|
||||
|
||||
func cwReferenceEarlierCount(c *common.CurrencyWarsCatalog, s *pb.BEEFLEJAOCN, kind string, sameChapter bool) (uint32, error) {
|
||||
rows, e := c.RouteNodes(s.EMOHLJHPOPC)
|
||||
if e != nil {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
|
||||
func TestCurrencyWarsGeneratedMonsterUsesOneStableCapturedCamp(t *testing.T) {
|
||||
c, err := common.LoadCurrencyWarsCatalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
refs, err := common.LoadCurrencyWarsReferenceNodes(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
policy, err := cwLoadReferenceEnemies(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var row common.CurrencyWarsRouteNode
|
||||
var tmpl common.CurrencyWarsReferenceNodeTemplate
|
||||
for _, candidate := range c.StageRoutes {
|
||||
node, e := refs.Template(candidate.NodeTemplateID)
|
||||
if e == nil && node.NodeType == "Monster" {
|
||||
row, tmpl = candidate, node
|
||||
break
|
||||
}
|
||||
}
|
||||
if row.RouteID == 0 {
|
||||
t.Fatal("captured route has no Monster node")
|
||||
}
|
||||
run := &pb.MPIMAEMCEFH{UniqueId: 42, LJNGPCKPJLF: c.ActiveSeasonID}
|
||||
stage := &pb.BEEFLEJAOCN{EMOHLJHPOPC: row.RouteID, DCAHJELNJNP: row.ChapterID, SectionId: row.SectionID}
|
||||
first, err := cwGenerateReferenceMonsterLayer(c, run, stage, tmpl, policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
again, err := cwGenerateReferenceMonsterLayer(c, run, stage, tmpl, policy)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layer, other := first.GetMPOBBIPMNNJ(), again.GetMPOBBIPMNNJ()
|
||||
if layer == nil || other == nil || layer.LNDKCPBJDKH != other.LNDKCPBJDKH || layer.MOADBALHMFD != 1 || len(layer.BPDABHGFBFO) != 1 {
|
||||
t.Fatal("monster camp or branch state differs")
|
||||
}
|
||||
camp, err := c.MonsterCamp(run.LJNGPCKPJLF, layer.LNDKCPBJDKH)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
allowed := map[uint32]bool{}
|
||||
for _, id := range camp.Season.MonsterList {
|
||||
allowed[id] = true
|
||||
}
|
||||
branch := layer.BPDABHGFBFO[0]
|
||||
if branch.IFIIOIIKJHO != tmpl.PenaltyBonusRuleID || len(branch.MonsterWaveList) != 1 {
|
||||
t.Fatal("monster wave or penalty differs")
|
||||
}
|
||||
previous, err := cwReferenceEarlierCount(c, stage, "Monster", false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := policy.Policy.LaterMonsterCount
|
||||
if previous == 0 {
|
||||
want = policy.Policy.FirstMonsterCount
|
||||
}
|
||||
if len(branch.MonsterWaveList[0].PBLBBDBDLEK) != int(want) {
|
||||
t.Fatal("monster count differs from policy")
|
||||
}
|
||||
for _, m := range branch.MonsterWaveList[0].PBLBBDBDLEK {
|
||||
if !allowed[m.MonsterId] || policy.MonsterTiers[m.MonsterId] == 0 || policy.MonsterTiers[m.MonsterId] > policy.Policy.MonsterMaxTier || m.RoleStar == 0 {
|
||||
t.Fatal("monster is outside captured eligible camp")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,7 +70,12 @@ func cwAdvanceRouteNode(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH
|
||||
shadowStage.DCAHJELNJNP = next.ChapterID
|
||||
shadowStage.SectionId = next.SectionID
|
||||
shadowStage.IMPNAHBCADN = nil
|
||||
layer, err := cwGenerateReferenceEnemyLayer(catalog, shadow, display)
|
||||
var layer *pb.NCKOKDHNJDC
|
||||
if template.NodeType == "Supply" {
|
||||
layer, err = cwCapturedSupplyLayer(catalog)
|
||||
} else {
|
||||
layer, err = cwGenerateReferenceEnemyLayer(catalog, shadow, display)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -101,6 +106,17 @@ func cwGeneratedRoundPreparation(catalog *common.CurrencyWarsCatalog, run *pb.MP
|
||||
if queue == math.MaxUint32 {
|
||||
return nil, fmt.Errorf("CW pending queue exhausted")
|
||||
}
|
||||
name, err := catalog.NodeTypeName(display.CJCCMAACJDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == "Supply" {
|
||||
offer, err := cwGenerateSupplyOffer(catalog, run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pb.CHCHAJIANIM{QueuePosition: queue + 1, FGLIODAJFML: &pb.CHCHAJIANIM_FIKAGBAEOAP{FIKAGBAEOAP: offer}}, nil
|
||||
}
|
||||
if display.HPNNLMBHOPF {
|
||||
offer, err := cwSpotlightChoices(catalog, run)
|
||||
if err != nil {
|
||||
@@ -108,10 +124,6 @@ func cwGeneratedRoundPreparation(catalog *common.CurrencyWarsCatalog, run *pb.MP
|
||||
}
|
||||
return &pb.CHCHAJIANIM{QueuePosition: queue + 1, FGLIODAJFML: &pb.CHCHAJIANIM_EEKMIAMGHFL{EEKMIAMGHFL: offer}}, nil
|
||||
}
|
||||
name, err := catalog.NodeTypeName(display.CJCCMAACJDB)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if name == "EliteBranch" && stage.IMPNAHBCADN.GetMPOBBIPMNNJ().MOADBALHMFD == 0 {
|
||||
return &pb.CHCHAJIANIM{QueuePosition: queue + 1, FGLIODAJFML: &pb.CHCHAJIANIM_BAKAKODKECM{BAKAKODKECM: &pb.HNPHLAHJPOB{}}}, nil
|
||||
}
|
||||
|
||||
@@ -95,12 +95,14 @@ func finishCurrencyWarsBattle(s *Session, req *pb.PVEBattleResultCsReq, current
|
||||
// base display rule. Validate against the battle we actually issued before
|
||||
// using that rule for HP/rewards or selecting the next transition.
|
||||
penaltyID := node.IFIIOIIKJHO
|
||||
var selectedBranch *pb.KKBGIPCAGGH
|
||||
if layer := stage.GetIMPNAHBCADN().GetMPOBBIPMNNJ(); layer != nil && len(layer.BPDABHGFBFO) > 0 {
|
||||
selected, e := cwSelectedEnemyBranch(layer)
|
||||
if e != nil {
|
||||
return e
|
||||
}
|
||||
penaltyID = selected.IFIIOIIKJHO
|
||||
selectedBranch = selected
|
||||
if current.GetEIGMKOJOEEI().GetIFIIOIIKJHO() != penaltyID {
|
||||
return fmt.Errorf("CW selected Encounter penalty differs from issued battle")
|
||||
}
|
||||
@@ -277,6 +279,13 @@ func finishCurrencyWarsBattle(s *Session, req *pb.PVEBattleResultCsReq, current
|
||||
if err = cwApplyBaseExperience(catalog, player, shop, uint32(gain)); err != nil {
|
||||
return err
|
||||
}
|
||||
var encounterUpdates []*pb.KFNCAIHIHCM
|
||||
if kind == "EliteBranch" {
|
||||
encounterUpdates, err = cwEncounterRewards(catalog, run, selectedBranch, progress, penalty.ThresholdPosition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
damage := &pb.BHBAILHMCOI{}
|
||||
frontMax, err := catalog.IntegerRule("GridFight_Front_AvatarMaxNum")
|
||||
if err != nil {
|
||||
@@ -338,6 +347,7 @@ func finishCurrencyWarsBattle(s *Session, req *pb.PVEBattleResultCsReq, current
|
||||
}
|
||||
CURRENT_BATTLE.Delete(s.Token) // Duplicate uploads cannot award again.
|
||||
update := cwNotify(cwUnlock(), cwGoldSync(player.Gold), cwCapacitySync(player), &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_GridFightLineupHp{GridFightLineupHp: &pb.FMJDDIHBELC{PPKKHHNEOHB: player.PPKKHHNEOHB, GridFightLineupHp: player.GridFightLineupHp}}}, &pb.KFNCAIHIHCM{MNLCHEJKDHO: &pb.KFNCAIHIHCM_PLPHEIBLPDL{PLPHEIBLPDL: &pb.PIHKLPJGEPM{Level: plan.Level, Exp: plan.Exp, HFMKLAPEKFA: player.NDAINFLOLPJ}}})
|
||||
update.GCPNIHHBGGD[0].JKPJFKCBIEM = append(update.GCPNIHHBGGD[0].JKPJFKCBIEM, encounterUpdates...)
|
||||
if err = s.Send(update); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -158,7 +158,21 @@ func cwSelectSupply(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, re
|
||||
}
|
||||
}
|
||||
if next == nil {
|
||||
return nil, fmt.Errorf("CW Supply next node not captured")
|
||||
// Generated routes do not necessarily have a captured next-node wire.
|
||||
// Keep the selected rewards on this detached run and let the common
|
||||
// route factory validate and construct the actual next node. If that
|
||||
// node family is not supported yet, the caller discards the snapshot.
|
||||
advance, err := cwAdvanceRouteNode(catalog, run)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("CW Supply next node: %w", err)
|
||||
}
|
||||
if len(advance.GCPNIHHBGGD) != 1 || len(advance.GCPNIHHBGGD[0].JKPJFKCBIEM) < 1 {
|
||||
return nil, fmt.Errorf("CW Supply advance notification incomplete")
|
||||
}
|
||||
updates := advance.GCPNIHHBGGD[0].JKPJFKCBIEM
|
||||
advance.GCPNIHHBGGD[0].JKPJFKCBIEM = append([]*pb.KFNCAIHIHCM{updates[0], cwUnlock()}, updates[1:]...)
|
||||
notify.GCPNIHHBGGD = append(notify.GCPNIHHBGGD, advance.GCPNIHHBGGD...)
|
||||
return notify, nil
|
||||
}
|
||||
level := new(pb.AINFNFIDGFF)
|
||||
nextPending := new(pb.CHCHAJIANIM)
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand/v2"
|
||||
"sort"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
// cwGenerateSupplyOffer follows the Supply option factory in the reference
|
||||
// executable identified in docs/cw-route-continuation-audit.md. The protocol
|
||||
// envelope comes from the native capture; role and equipment IDs must also be
|
||||
// present in this client's captured data.
|
||||
func cwGenerateSupplyOffer(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) (*pb.IAKEDFLIOFD, error) {
|
||||
stage, _ := cwStage(run)
|
||||
if stage == nil || stage.DCAHJELNJNP == 0 {
|
||||
return nil, fmt.Errorf("CW Supply chapter missing")
|
||||
}
|
||||
runtime, err := cwBonfireRuntime(c, run)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return cwGenerateSupplyOfferWithRules(c, run, runtime.Rules)
|
||||
}
|
||||
|
||||
func cwGenerateSupplyOfferWithRules(c *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH, rules map[string]uint32) (*pb.IAKEDFLIOFD, error) {
|
||||
stage, _ := cwStage(run)
|
||||
if stage == nil || stage.DCAHJELNJNP == 0 {
|
||||
return nil, fmt.Errorf("CW Supply chapter missing")
|
||||
}
|
||||
// Box modifies a different part of the selection flow; do not silently
|
||||
// grant an ordinary selection if the corresponding action is not mapped.
|
||||
if rules["SupplyBox"] != 0 {
|
||||
return nil, fmt.Errorf("CW SupplyBox action is not mapped")
|
||||
}
|
||||
count, err := c.IntegerRule("GridFight_BaseSupplyRoleNum")
|
||||
// gfFxHProjectRunState projects ExtraSupplyOption into the field added by
|
||||
// gfPendingFillSupplyOptions to the Client's captured base count.
|
||||
if err != nil || count <= 0 || uint64(count)+uint64(rules["ExtraSupplyOption"]) > uint64(len(c.CapturedRoles)) {
|
||||
return nil, fmt.Errorf("CW Supply option count invalid")
|
||||
}
|
||||
count += int64(rules["ExtraSupplyOption"])
|
||||
star := rules["SupplyStar"]
|
||||
if star == 0 {
|
||||
star = 1
|
||||
} // gfPendingSupplyOf clamps this rule to one.
|
||||
category := "Basic"
|
||||
if rules["SupplyEmblems"] != 0 {
|
||||
category = "Emblem"
|
||||
} else if stage.DCAHJELNJNP >= 3 {
|
||||
category = "Craftable"
|
||||
}
|
||||
recipes, err := common.LoadCurrencyWarsOrbRecipes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gear, err := common.LoadCurrencyWarsEquipment(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var equipment []uint32
|
||||
for _, item := range recipes.Equipment {
|
||||
if item.Category == category {
|
||||
if _, ok := gear[item.ID]; !ok {
|
||||
return nil, fmt.Errorf("CW Supply equipment %d is not captured", item.ID)
|
||||
}
|
||||
equipment = append(equipment, item.ID)
|
||||
}
|
||||
}
|
||||
if len(equipment) == 0 {
|
||||
return nil, fmt.Errorf("CW Supply equipment category %s is empty", category)
|
||||
}
|
||||
sort.Slice(equipment, func(i, j int) bool { return equipment[i] < equipment[j] })
|
||||
pool, err := c.NewCapturedCardPool()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
byRarity := make(map[uint32][]uint32)
|
||||
for id, item := range pool.Roles {
|
||||
if item.Disabled || item.Remaining == 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := c.CapturedRoleVariant(id, star); err != nil {
|
||||
continue
|
||||
}
|
||||
byRarity[item.Rarity] = append(byRarity[item.Rarity], id)
|
||||
}
|
||||
for _, ids := range byRarity {
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
}
|
||||
entries, err := cwNodeTransitions(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var offer *pb.IAKEDFLIOFD
|
||||
for _, entry := range entries {
|
||||
pending := new(pb.CHCHAJIANIM)
|
||||
if err := proto.Unmarshal(entry.RoundWire, pending); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if captured := pending.GetFIKAGBAEOAP(); captured != nil {
|
||||
candidate := proto.Clone(captured).(*pb.IAKEDFLIOFD)
|
||||
candidate.MFBAKLCBPCJ = nil
|
||||
if offer != nil && !proto.Equal(offer, candidate) {
|
||||
return nil, fmt.Errorf("CW Supply envelopes differ")
|
||||
}
|
||||
offer = candidate
|
||||
}
|
||||
}
|
||||
if offer == nil {
|
||||
return nil, fmt.Errorf("CW Supply envelope not captured")
|
||||
}
|
||||
used := map[uint32]bool{}
|
||||
for i := int64(0); i < int64(count); i++ {
|
||||
var id uint32
|
||||
for attempt := 0; attempt < 8; attempt++ {
|
||||
rarity := rules["SupplyRarity"]
|
||||
if rarity == 0 {
|
||||
rarity = min(uint32(rand.IntN(int(stage.DCAHJELNJNP+2)))+1, uint32(5))
|
||||
}
|
||||
// gfPoolRandomRoleFiltered searches the requested rarity first,
|
||||
// then neighbouring rarity buckets at increasing distance.
|
||||
for distance := uint32(0); distance < 5 && id == 0; distance++ {
|
||||
for _, candidate := range []int64{int64(rarity) - int64(distance), int64(rarity) + int64(distance)} {
|
||||
if candidate < 1 || candidate > 5 {
|
||||
continue
|
||||
}
|
||||
ids := byRarity[uint32(candidate)]
|
||||
if len(ids) != 0 {
|
||||
id = ids[rand.IntN(len(ids))]
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if id != 0 && !used[id] {
|
||||
break
|
||||
}
|
||||
id = 0
|
||||
}
|
||||
if id == 0 {
|
||||
continue
|
||||
}
|
||||
used[id] = true
|
||||
item := equipment[rand.IntN(len(equipment))]
|
||||
offer.MFBAKLCBPCJ = append(offer.MFBAKLCBPCJ, &pb.NBGDEFLFLMI{RoleId: id, OPPFPECOBEF: &pb.BCCONKJHDJL{FKKGLFOHGGA: pb.MJJIFGINJAJ_MJJIFGINJAJ_BDPIMPJOJBK, FNLMKJLKLFH: id, Num: 1, DisplayValue: &pb.AOHKKFHDALO{NEBNJDMFELO: &pb.AOHKKFHDALO_RoleStar{RoleStar: star}}}, GridFightItemList: []uint32{item}, DHLBEFLOOPM: []*pb.BCCONKJHDJL{{FKKGLFOHGGA: pb.MJJIFGINJAJ_MJJIFGINJAJ_NLFCABFPJHD, FNLMKJLKLFH: item, Num: 1}}})
|
||||
}
|
||||
if offer.DAEMEBCKHIK == 0 || uint64(len(offer.MFBAKLCBPCJ)) < uint64(offer.DAEMEBCKHIK) {
|
||||
return nil, fmt.Errorf("CW Supply has too few valid role options")
|
||||
}
|
||||
chance, item := rules["SupplyEquipChance"], rules["SupplyEquip"]
|
||||
if chance > 100 {
|
||||
return nil, fmt.Errorf("CW Supply equipment chance invalid")
|
||||
}
|
||||
if chance != 0 && item != 0 && stage.DCAHJELNJNP >= rules["SupplyEquipChapter"] {
|
||||
if _, ok := gear[item]; !ok {
|
||||
return nil, fmt.Errorf("CW Supply bonus equipment %d is not captured", item)
|
||||
}
|
||||
if uint32(rand.IntN(100)) < chance {
|
||||
choice := offer.MFBAKLCBPCJ[rand.IntN(len(offer.MFBAKLCBPCJ))]
|
||||
choice.GridFightItemList[0] = item
|
||||
choice.DHLBEFLOOPM[0].FNLMKJLKLFH = item
|
||||
}
|
||||
}
|
||||
return offer, nil
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
|
||||
func TestCurrencyWarsSupplyOffersUseCapturedRolesAndEquipment(t *testing.T) {
|
||||
c, err := common.LoadCurrencyWarsCatalog()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
run := &pb.MPIMAEMCEFH{NAGDJGMMCBE: []*pb.GridFightGameInfo{{LEOIAOFBBFA: &pb.GridFightGameInfo_KLCPAAHDPJF{KLCPAAHDPJF: &pb.BEEFLEJAOCN{DCAHJELNJNP: 2}}}}}
|
||||
gear, err := common.LoadCurrencyWarsEquipment(c)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for n := 0; n < 10; n++ {
|
||||
offer, err := cwGenerateSupplyOfferWithRules(c, run, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if offer.DAEMEBCKHIK == 0 || len(offer.MFBAKLCBPCJ) < int(offer.DAEMEBCKHIK) {
|
||||
t.Fatal("Supply has too few options")
|
||||
}
|
||||
used := map[uint32]bool{}
|
||||
for _, item := range offer.MFBAKLCBPCJ {
|
||||
if item == nil || used[item.RoleId] {
|
||||
t.Fatal("Supply role is missing or duplicated")
|
||||
}
|
||||
used[item.RoleId] = true
|
||||
drop := item.GetOPPFPECOBEF()
|
||||
if drop == nil || drop.FNLMKJLKLFH != item.RoleId {
|
||||
t.Fatal("Supply role display differs")
|
||||
}
|
||||
if _, err := c.CapturedRoleVariant(item.RoleId, drop.GetDisplayValue().GetRoleStar()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(item.GridFightItemList) != 1 || len(item.DHLBEFLOOPM) != 1 || item.GridFightItemList[0] != item.DHLBEFLOOPM[0].FNLMKJLKLFH {
|
||||
t.Fatal("Supply gear display differs")
|
||||
}
|
||||
if _, ok := gear[item.GridFightItemList[0]]; !ok {
|
||||
t.Fatal("Supply gear is not captured")
|
||||
}
|
||||
}
|
||||
}
|
||||
if _, err := cwGenerateSupplyOfferWithRules(c, run, map[string]uint32{"ExtraSupplyOption": ^uint32(0)}); err == nil {
|
||||
t.Fatal("excessive Supply option count must be rejected")
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
package gameserver
|
||||
|
||||
import (
|
||||
"time"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
|
||||
goproto "gac-hkrpg-go/protocol/proto"
|
||||
)
|
||||
@@ -23,7 +23,11 @@ func init() {
|
||||
rsp.Retcode = 0
|
||||
rsp.CurTimezone = 8
|
||||
rsp.LoginRandom = req.LoginRandom
|
||||
rsp.ServerTimestampMs = uint64(time.Now().UnixMilli())
|
||||
now, err := common.GameTime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsp.ServerTimestampMs = uint64(now.UnixMilli())
|
||||
rsp.Stamina = 240
|
||||
rsp.BasicInfo = &goproto.PlayerBasicInfo{
|
||||
Nickname: playerName,
|
||||
|
||||
@@ -90,6 +90,17 @@ func getChallengeData() *ChallengeDataConfig {
|
||||
Log("Failed to parse challenge_data.json: " + err.Error())
|
||||
challengeData = ChallengeDataConfig{}
|
||||
}
|
||||
// The imported catalog stores Peak rows under peak.stages; the flat
|
||||
// resource overlays below add Client-specific placement/group data.
|
||||
// Do not turn the section names "stages" and "groups" into fake PeakIDs.
|
||||
var imported struct {
|
||||
Peak struct {
|
||||
Stages map[string]PeakStageData `json:"stages"`
|
||||
} `json:"peak"`
|
||||
}
|
||||
if json.Unmarshal(raw, &imported) == nil && len(imported.Peak.Stages) != 0 {
|
||||
challengeData.Peak = imported.Peak.Stages
|
||||
}
|
||||
challengeData.Countdowns = map[uint32]uint32{}
|
||||
challengeData.TargetRules = map[uint32]ChallengeTargetRule{}
|
||||
if raw, err := common.ReadResourceFile("resources/ChallengeTargetConfig.json"); err == nil {
|
||||
|
||||
@@ -2,6 +2,8 @@ package gameserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
@@ -42,10 +44,19 @@ func init() {
|
||||
PacketLog("CW_GM deferred: " + err.Error())
|
||||
}
|
||||
rsp.ClientTimeMs = req.ClientTimeMs
|
||||
rsp.ServerTimeMs = req.ClientTimeMs
|
||||
now, err := common.GameTime()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rsp.ServerTimeMs = uint64(now.UnixMilli())
|
||||
|
||||
var finalData []byte
|
||||
finalData = append(finalData, heartbeatLua...)
|
||||
if dir, err := os.Getwd(); err == nil {
|
||||
finalData = append(finalData, []byte("\n_G.__GAC_DIAGNOSTICS_DIR = "+luaQuotedString(filepath.Join(dir, "logs"))+"\n")...)
|
||||
} else {
|
||||
PacketLog("Client diagnostics directory unavailable: " + err.Error())
|
||||
}
|
||||
if s.publicSDKURL != "" {
|
||||
finalData = append(finalData, []byte("\n_G.__GAC_SRTOOL_LIVE_URL = "+luaQuotedString(s.publicSDKURL)+"\n")...)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package gameserver
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPeakSceneCatalogMatchesClientGroups(t *testing.T) {
|
||||
chdirRepositoryRoot(t)
|
||||
data := getChallengeData()
|
||||
if len(data.Peak) == 0 {
|
||||
t.Fatal("no AA stages loaded")
|
||||
}
|
||||
for id, stage := range data.Peak {
|
||||
if stage.GroupID == 0 || stage.Entrance == 0 || stage.Group == 0 || stage.MonsterID == 0 {
|
||||
t.Errorf("stage %s has incomplete placement", id)
|
||||
}
|
||||
if stage.SceneResourceError != "" {
|
||||
t.Errorf("stage %s rejected: %s", id, stage.SceneResourceError)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,7 @@ func registerAPIRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/teleport", corsWrapper(apiTeleport))
|
||||
mux.HandleFunc("/api/gachabase/fetch", corsWrapper(apiGachabaseFetch))
|
||||
mux.HandleFunc("/api/command", corsWrapper(apiCommand))
|
||||
mux.HandleFunc("/api/command-completions", corsWrapper(apiCommandCompletions))
|
||||
mux.HandleFunc("/api/client/unstuck", corsWrapper(apiClientUnstuck))
|
||||
mux.HandleFunc("/api/client/unstuck/result", corsWrapper(apiClientUnstuckResult))
|
||||
mux.HandleFunc("/api/data-in-game", corsWrapper(apiDataInGame))
|
||||
@@ -803,6 +804,12 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
apiCurrencyWarsCommand(w, cmd)
|
||||
return
|
||||
}
|
||||
if parts := strings.Fields(cmd); len(parts) > 0 && strings.EqualFold(parts[0], "/faketime") {
|
||||
apiMu.Lock()
|
||||
defer apiMu.Unlock()
|
||||
apiFakeTimeCommand(w, cmd)
|
||||
return
|
||||
}
|
||||
|
||||
apiMu.Lock()
|
||||
defer apiMu.Unlock()
|
||||
@@ -896,7 +903,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
case "/help":
|
||||
output = "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Teleport to scene\n /avatar <id> [level] [rank] - Modify avatar\n /give <item_id> [count] - Add items to inventory\n /clear lineup - Clear active lineup slots"
|
||||
output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
|
||||
output += "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Teleport to scene\n /avatar <id> [level] [rank] - Modify avatar\n /give <item_id> [count] - Add items to inventory\n /clear lineup - Clear active lineup slots"
|
||||
|
||||
default:
|
||||
output = fmt.Sprintf("[OK] Custom command '%s' dispatched to server.", cmd)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type commandCompletion struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Plane uint32 `json:"plane,omitempty"`
|
||||
Star uint32 `json:"star,omitempty"`
|
||||
Avatar uint32 `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
// Read-only, resource-backed argument choices. No save or live run is changed.
|
||||
func apiCommandCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
out := map[string][]commandCompletion{}
|
||||
add := func(kind string, id uint32, label string) {
|
||||
out[kind] = append(out[kind], commandCompletion{Value: fmt.Sprint(id), Label: label})
|
||||
}
|
||||
var issues []string
|
||||
if raw, err := common.ReadResourceFile("resources/InventoryItemConfig.json"); err == nil {
|
||||
var items struct {
|
||||
IDs []uint32 `json:"item_ids"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &items); err == nil {
|
||||
for _, id := range items.IDs {
|
||||
add("items", id, fmt.Sprintf("Item %d", id))
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
if res, err := common.GameRes(); err == nil {
|
||||
planes := map[uint32]bool{}
|
||||
floors := map[[2]uint32]bool{}
|
||||
for _, entry := range res.MapEntrances {
|
||||
if !planes[entry.PlaneID] {
|
||||
add("planes", entry.PlaneID, fmt.Sprintf("Plane %d", entry.PlaneID))
|
||||
planes[entry.PlaneID] = true
|
||||
}
|
||||
key := [2]uint32{entry.PlaneID, entry.FloorID}
|
||||
if !floors[key] {
|
||||
out["floors"] = append(out["floors"], commandCompletion{Value: fmt.Sprint(entry.FloorID), Label: fmt.Sprintf("Floor %d", entry.FloorID), Plane: entry.PlaneID})
|
||||
floors[key] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
if catalog, err := common.LoadCurrencyWarsCatalog(); err == nil {
|
||||
for _, v := range catalog.CapturedVariants {
|
||||
out["cw_roles"] = append(out["cw_roles"], commandCompletion{Value: fmt.Sprint(v.Config.ID), Label: fmt.Sprintf("Role %d · %d★", v.Config.ID, v.Star), Star: v.Star, Avatar: v.Config.AvatarID})
|
||||
}
|
||||
if equipment, e := common.LoadCurrencyWarsEquipment(catalog); e == nil {
|
||||
for id, row := range equipment {
|
||||
add("cw_equip", id, row.AbilityName)
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, e.Error())
|
||||
}
|
||||
if rewards, e := common.LoadCurrencyWarsRewards(catalog); e == nil {
|
||||
for id := range rewards.Orbs {
|
||||
add("cw_orb", id, fmt.Sprintf("Orb %d", id))
|
||||
}
|
||||
for id := range rewards.Consumables {
|
||||
add("cw_consumable", id, fmt.Sprintf("Consumable %d", id))
|
||||
}
|
||||
for id := range rewards.Forges {
|
||||
add("cw_forge", id, fmt.Sprintf("Forge %d", id))
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, e.Error())
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
for key := range out {
|
||||
sort.Slice(out[key], func(i, j int) bool {
|
||||
if out[key][i].Value == out[key][j].Value {
|
||||
return out[key][i].Star < out[key][j].Star
|
||||
}
|
||||
return out[key][i].Value < out[key][j].Value
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"choices": out, "issues": issues})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
parts := strings.Fields(cmd)
|
||||
usage := "Usage: /faketime [status | off | <Unix seconds> | <RFC3339 date with timezone>]. Time keeps advancing. Reopen the guide after the next heartbeat; relogin if it caches schedules."
|
||||
var now time.Time
|
||||
var err error
|
||||
arg := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
|
||||
switch strings.ToLower(arg) {
|
||||
case "", "status":
|
||||
now, err = common.GameTime()
|
||||
case "off", "reset", "real":
|
||||
now, err = common.SetGameTime(nil)
|
||||
default:
|
||||
var target time.Time
|
||||
if seconds, e := strconv.ParseInt(arg, 10, 64); e == nil {
|
||||
if seconds < 0 || seconds > 253402300799 {
|
||||
err = fmt.Errorf("Unix seconds outside supported calendar range")
|
||||
} else {
|
||||
target = time.Unix(seconds, 0)
|
||||
}
|
||||
} else {
|
||||
target, err = time.Parse(time.RFC3339, arg)
|
||||
}
|
||||
if err == nil {
|
||||
now, err = common.SetGameTime(&target)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"success": false, "output": err.Error() + "\n" + usage})
|
||||
return
|
||||
}
|
||||
state, err := common.ReadGameClock()
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"success": false, "output": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "output": fmt.Sprintf("Game time: %s (offset %d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, usage)})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
)
|
||||
|
||||
// Route name is part of this server's HTTP interface, not a machine URL.
|
||||
const luaOverlayRoute = "/gacy-lua-overlay"
|
||||
|
||||
var luaBundleName = regexp.MustCompile(`^(?:M_LuaV|LuaV_[0-9a-f]{32}|[0-9a-f]{32})\.bytes$`)
|
||||
|
||||
// Dispatch outside ServeMux: a subtree registration conflicts with the
|
||||
// product-name wildcard used by the existing login endpoints in newer Go.
|
||||
func withLuaOverlay(next http.Handler, cfg common.LuaOverlayConfig) http.Handler {
|
||||
if !cfg.Enabled {
|
||||
return next
|
||||
}
|
||||
overlay := luaOverlayHandler(cfg)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, luaOverlayRoute+"/") {
|
||||
overlay(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Serve only the Lua updater's known flat namespace. Stage files win, then the
|
||||
// local hotfix and shipping files; no filesystem path or remote URL is derived
|
||||
// from untrusted input. The original caches remain untouched.
|
||||
func luaOverlayHandler(cfg common.LuaOverlayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, luaOverlayRoute+"/")
|
||||
path = strings.TrimPrefix(path, "client/Windows/")
|
||||
if !luaBundleName.MatchString(path) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
for _, root := range []string{cfg.StageDir, cfg.HotfixDir, cfg.StreamingDir} {
|
||||
file, err := os.Open(filepath.Join(root, path))
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Lua overlay read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
file.Close()
|
||||
http.Error(w, "Lua overlay file invalid", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
http.ServeContent(w, r, path, info.ModTime(), file)
|
||||
file.Close()
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestLuaOverlayGatewayAndLookup(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
cfg := common.LuaOverlayConfig{Enabled: true, StageDir: filepath.Join(base, "stage"), HotfixDir: filepath.Join(base, "hotfix"), StreamingDir: filepath.Join(base, "streaming")}
|
||||
for _, dir := range []string{cfg.StageDir, cfg.HotfixDir, cfg.StreamingDir} {
|
||||
if err := os.Mkdir(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, data := range []struct{ dir, name, content string }{{cfg.StageDir, "M_LuaV.bytes", "stage"}, {cfg.HotfixDir, "M_LuaV.bytes", "old"}, {cfg.StreamingDir, "0123456789abcdef0123456789abcdef.bytes", "base"}} {
|
||||
if err := os.WriteFile(filepath.Join(data.dir, data.name), []byte(data.content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h := luaOverlayHandler(cfg)
|
||||
for _, tt := range []struct {
|
||||
path, want string
|
||||
code int
|
||||
}{{luaOverlayRoute + "/client/Windows/M_LuaV.bytes", "stage", 200}, {luaOverlayRoute + "/0123456789abcdef0123456789abcdef.bytes", "base", 200}, {luaOverlayRoute + "/client/Windows/../../secret", "404 page not found\n", 404}} {
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
if w.Code != tt.code || w.Body.String() != tt.want {
|
||||
t.Fatalf("%s: %d %q", tt.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
// The existing SDK has product-name wildcard routes; overlay dispatch must
|
||||
// coexist with them without a ServeMux registration panic.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
wrapped := withLuaOverlay(mux, cfg)
|
||||
for _, tt := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{{"/game/mdk/shield/api/login", http.StatusNoContent}, {luaOverlayRoute + "/M_LuaV.bytes", http.StatusOK}} {
|
||||
w := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
if w.Code != tt.want {
|
||||
t.Fatalf("%s: got %d want %d", tt.path, w.Code, tt.want)
|
||||
}
|
||||
}
|
||||
state := &appState{runtime: common.RuntimeConfig{PublicSDKURL: "https://sdk.example.invalid", PublicGameHost: "host.example.invalid", GamePort: 1, LuaOverlay: cfg}, hotfixMap: map[string]VersionConfig{"fixture": {LuaURL: "https://upstream.example.invalid"}}}
|
||||
w := httptest.NewRecorder()
|
||||
queryGateway(state)(w, httptest.NewRequest(http.MethodGet, "/query_gateway?version=fixture", nil))
|
||||
wire, err := base64.StdEncoding.DecodeString(w.Body.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gateway pb.Gateserver
|
||||
if err := proto.Unmarshal(wire, &gateway); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gateway.LuaUrl != state.runtime.PublicSDKURL+luaOverlayRoute {
|
||||
t.Fatalf("wrong Lua URL: %q", gateway.LuaUrl)
|
||||
}
|
||||
if state.hotfixMap["fixture"].LuaURL != "https://upstream.example.invalid" {
|
||||
t.Fatal("upstream config was mutated")
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,9 @@ func queryGateway(state *appState) http.HandlerFunc {
|
||||
_ = params{}
|
||||
|
||||
cfg := state.getOrInsertHotfix(version, seed)
|
||||
if state.runtime.LuaOverlay.Enabled {
|
||||
cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute
|
||||
}
|
||||
|
||||
rsp := &goproto.Gateserver{
|
||||
Ip: state.runtime.PublicGameHost,
|
||||
@@ -386,7 +389,7 @@ func Listen() error {
|
||||
addr := runtime.SDKAddress()
|
||||
Log("sdkserver is listening at " + addr)
|
||||
Log(fmt.Sprintf("REST API Gateway is live at %s/ (for GaC-Srtool)", runtime.PublicSDKURL))
|
||||
return http.ListenAndServe(addr, mux)
|
||||
return http.ListenAndServe(addr, withLuaOverlay(mux, runtime.LuaOverlay))
|
||||
}
|
||||
|
||||
func Log(msg string) { fmt.Println("[sdkserver]", msg) }
|
||||
@@ -107,11 +107,6 @@
|
||||
{
|
||||
"group_id": 5,
|
||||
"stages": {
|
||||
"6": {
|
||||
"npc_monster_id": 5013040,
|
||||
"map_entrance_id": 3014101,
|
||||
"maze_group_id": 5
|
||||
},
|
||||
"502": {
|
||||
"npc_monster_id": 4014030,
|
||||
"map_entrance_id": 3014101,
|
||||
@@ -126,6 +121,11 @@
|
||||
"npc_monster_id": 5014020,
|
||||
"map_entrance_id": 3014101,
|
||||
"maze_group_id": 5
|
||||
},
|
||||
"501": {
|
||||
"npc_monster_id": 5013040,
|
||||
"map_entrance_id": 3014101,
|
||||
"maze_group_id": 5
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -283,5 +283,14 @@
|
||||
},
|
||||
"source_kind": "reference-server-embedded-data",
|
||||
"evidence": "FFGO updated 2026-10-26; battle_diagnostics/ffgo-20261026/challenge_peak_extra_data.json",
|
||||
"verification": "Reference mapping only; installed Client scene/NPC acceptance remains unverified. Battle events and waves remain from existing Client resources."
|
||||
"verification": "Reference mapping only; installed Client scene/NPC acceptance remains unverified. Battle events and waves remain from existing Client resources.",
|
||||
"client_stage_key_corrections": [
|
||||
{
|
||||
"group_id": 5,
|
||||
"reference_stage_key": "6",
|
||||
"client_stage_id": 501,
|
||||
"npc_monster_id": 5013040,
|
||||
"basis": "Unique missing stage in ChallengePeakGroupConfig.PreLevelIDList with identical ChallengePeakConfig.NpcMonsterIDList actor; placement values unchanged."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -12,6 +12,7 @@
|
||||
"minion_filter": "gfRouteMinions RVA0xcdb020"
|
||||
},
|
||||
"policy": {
|
||||
"neutral_camp_compatibility": "stable_random_enabled_season_camp_by_run_id",
|
||||
"first_chapter_boss_star": 2,
|
||||
"later_chapter_boss_star": 3,
|
||||
"first_chapter": 1,
|
||||
|
||||
@@ -102,6 +102,15 @@ try {
|
||||
$value = [Environment]::GetEnvironmentVariable($variables[$key])
|
||||
if ($null -ne $value) { $settings | Add-Member -MemberType NoteProperty -Name $key -Value $value -Force }
|
||||
}
|
||||
$clockPathOverride = [Environment]::GetEnvironmentVariable('GAC_GAME_CLOCK_STATE_FILE')
|
||||
if ($null -ne $clockPathOverride) {
|
||||
if (-not $settings.game_clock) { $settings | Add-Member -MemberType NoteProperty -Name game_clock -Value ([pscustomobject]@{}) }
|
||||
$settings.game_clock | Add-Member -MemberType NoteProperty -Name state_file -Value $clockPathOverride -Force
|
||||
}
|
||||
if ($settings.game_clock -and $null -ne $settings.game_clock.state_file) {
|
||||
if ($settings.game_clock.state_file -isnot [string] -or [string]::IsNullOrWhiteSpace($settings.game_clock.state_file)) { throw 'game_clock.state_file must be a nonempty path.' }
|
||||
if (Test-Path -LiteralPath (Resolve-ProjectPath $settings.game_clock.state_file) -PathType Container) { throw 'game_clock.state_file must be a file path.' }
|
||||
}
|
||||
foreach ($pair in @(@('begin_time','GAC_ACTIVITY_BEGIN_TIME'),@('end_time','GAC_ACTIVITY_END_TIME'))) {
|
||||
$value = [Environment]::GetEnvironmentVariable($pair[1])
|
||||
if ($null -ne $value) {
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
if not _G.__GAC_FACECAM_PROBE_BATTLE then
|
||||
local ok,err=pcall(function()
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local phase=typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
|
||||
if not phase or not typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
|
||||
_G.__GAC_FACECAM_PROBE_BATTLE=true
|
||||
local t=phase:GetType()
|
||||
CS.UnityEngine.Debug.Log('[FaceCam battle] phase='..tostring(t))
|
||||
for _,members in ipairs({t:GetFields(flags),t:GetProperties(flags)}) do
|
||||
for i=0,math.min(members.Length-1,199) do
|
||||
local m=members[i];local n=tostring(m.Name):lower()
|
||||
if n:find('actor') or n:find('entity') or n:find('avatar') or n:find('turn') or n:find('battle') or n:find('stage') then
|
||||
CS.UnityEngine.Debug.Log('[FaceCam battle] member='..tostring(m.Name)..' detail='..tostring(m))
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
if not ok then _G.__GAC_FACECAM_PROBE_BATTLE=true;CS.UnityEngine.Debug.Log('[FaceCam battle] error='..tostring(err)) end
|
||||
end
|
||||
@@ -0,0 +1,252 @@
|
||||
do
|
||||
local VERSION = 7
|
||||
local MODULE_NAMES = {
|
||||
'Logic.SettingModule.SettingEnum',
|
||||
'Logic/SettingModule/SettingEnum',
|
||||
'@BakedLua/Logic/SettingModule/SettingEnum',
|
||||
'SettingEnum',
|
||||
}
|
||||
local state = _G.__GAC_FPS_SETTINGS_120_STATE
|
||||
local Unity = CS.UnityEngine
|
||||
|
||||
local function remove_callback(callback)
|
||||
if not callback then return end
|
||||
for _, event in ipairs({
|
||||
{Unity.Application, 'onBeforeRender'},
|
||||
{Unity.Camera, 'onPreCull'},
|
||||
}) do
|
||||
pcall(function() event[1][event[2]]('-', callback) end)
|
||||
pcall(function() event[1][event[2]] = event[1][event[2]] - callback end)
|
||||
end
|
||||
end
|
||||
|
||||
local function add_callback(callback)
|
||||
for _, event in ipairs({
|
||||
{Unity.Application, 'onBeforeRender'},
|
||||
{Unity.Camera, 'onPreCull'},
|
||||
}) do
|
||||
local ok = pcall(function() event[1][event[2]]('+', callback) end)
|
||||
if not ok then
|
||||
ok = pcall(function()
|
||||
event[1][event[2]] = event[1][event[2]] + callback
|
||||
end)
|
||||
end
|
||||
if ok then return true, event[1], event[2] end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
if state and state.version ~= VERSION then
|
||||
remove_callback(state.callback)
|
||||
if state.module and state.original_getter then
|
||||
pcall(function()
|
||||
state.module.get_graphics_data_by_type = state.original_getter
|
||||
end)
|
||||
end
|
||||
state = nil
|
||||
end
|
||||
|
||||
state = state or {version = VERSION, next_check = 0}
|
||||
_G.__GAC_FPS_SETTINGS_120_STATE = state
|
||||
|
||||
local function log(message)
|
||||
Unity.Debug.Log('[GaC 120 FPS Settings] ' .. message)
|
||||
end
|
||||
|
||||
if not state.load_logged then
|
||||
state.load_logged = true
|
||||
log('script loaded; checking the native Graphics settings module')
|
||||
end
|
||||
|
||||
local function add_native_option(list, text_id)
|
||||
if type(list) ~= 'table' then return false end
|
||||
|
||||
local next_key = 1
|
||||
local has_30_fps = false
|
||||
local has_60_fps = false
|
||||
for _, option in ipairs(list) do
|
||||
if type(option) == 'table' then
|
||||
if option.setting == 120 then return true, false end
|
||||
if option.setting == 30 and option.text_id == 'UIText_Setting_Level_30FPS' then
|
||||
has_30_fps = true
|
||||
elseif option.setting == 60 and option.text_id == 'UIText_Setting_Level_60FPS' then
|
||||
has_60_fps = true
|
||||
end
|
||||
if type(option.key) == 'number' and option.key >= next_key then
|
||||
next_key = option.key + 1
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
if not has_30_fps or not has_60_fps then return false, false end
|
||||
|
||||
table.insert(list, {
|
||||
key = next_key,
|
||||
text_id = text_id,
|
||||
setting = 120,
|
||||
})
|
||||
return true, true
|
||||
end
|
||||
|
||||
local function patch_native_data(module)
|
||||
local graphics_data = rawget(module, '_graphics_data')
|
||||
if type(graphics_data) ~= 'table' then
|
||||
if not state.data_probe_logged then
|
||||
state.data_probe_logged = true
|
||||
log('native _graphics_data is not available on the settings module')
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local found_fps_list = false
|
||||
local added = false
|
||||
for setting_type, list in pairs(graphics_data) do
|
||||
local ok, matched, inserted = pcall(add_native_option, list, 'UIText_Setting_Level_120FPS')
|
||||
if ok and matched then
|
||||
found_fps_list = true
|
||||
if inserted then added = true end
|
||||
if not state.data_probe_logged then
|
||||
state.data_probe_logged = true
|
||||
log('found native FPS rows in _graphics_data[' .. tostring(setting_type) .. ']')
|
||||
end
|
||||
elseif not ok and not state.data_probe_logged then
|
||||
state.data_probe_logged = true
|
||||
log('_graphics_data probe failed: ' .. tostring(matched))
|
||||
end
|
||||
end
|
||||
|
||||
if added and not state.row_added then
|
||||
state.row_added = true
|
||||
log('added the 120 FPS option to the native Graphics data')
|
||||
elseif not found_fps_list and not state.data_probe_logged then
|
||||
state.data_probe_logged = true
|
||||
log('native _graphics_data is present but contains no matching 30/60 FPS rows')
|
||||
end
|
||||
return found_fps_list
|
||||
end
|
||||
|
||||
local function find_setting_module()
|
||||
for _, name in ipairs(MODULE_NAMES) do
|
||||
local loaded = package.loaded[name]
|
||||
if type(loaded) == 'table' and type(loaded.get_graphics_data_by_type) == 'function' then
|
||||
state.module_alias = name
|
||||
return loaded
|
||||
end
|
||||
end
|
||||
|
||||
if not state.require_attempted then
|
||||
state.require_attempted = true
|
||||
local outcomes = {}
|
||||
for _, name in ipairs(MODULE_NAMES) do
|
||||
local ok, result = pcall(require, name)
|
||||
if ok and type(result) == 'table' and type(result.get_graphics_data_by_type) == 'function' then
|
||||
state.module_alias = name
|
||||
return result
|
||||
end
|
||||
local loaded = package.loaded[name]
|
||||
if type(loaded) == 'table' and type(loaded.get_graphics_data_by_type) == 'function' then
|
||||
state.module_alias = name
|
||||
return loaded
|
||||
end
|
||||
outcomes[#outcomes + 1] = name .. '=' .. (ok and (type(result) .. ':' .. tostring(result)) or tostring(result))
|
||||
end
|
||||
state.require_error = table.concat(outcomes, '; ')
|
||||
end
|
||||
|
||||
|
||||
local global_registry = rawget(_G, 'G')
|
||||
for _, entry in ipairs({
|
||||
{value = rawget(_G, 'SettingModule'), name = '_G.SettingModule'},
|
||||
{value = rawget(_G, 'SettingEnum'), name = '_G.SettingEnum'},
|
||||
{value = type(global_registry) == 'table' and rawget(global_registry, 'SettingModule') or nil, name = 'G.SettingModule'},
|
||||
{value = type(global_registry) == 'table' and rawget(global_registry, 'SettingEnum') or nil, name = 'G.SettingEnum'},
|
||||
}) do
|
||||
local candidate = entry.value
|
||||
if type(candidate) == 'table' and type(candidate.get_graphics_data_by_type) == 'function' then
|
||||
state.module_alias = entry.name
|
||||
return candidate
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
local function install()
|
||||
local module = find_setting_module()
|
||||
if type(module) ~= 'table' then
|
||||
if not state.module_wait_logged then
|
||||
state.module_wait_logged = true
|
||||
log('SettingEnum lookup failed; ' .. tostring(state.require_error))
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
local getter = module.get_graphics_data_by_type
|
||||
if type(getter) ~= 'function' then
|
||||
if not state.getter_wait_logged then
|
||||
state.getter_wait_logged = true
|
||||
log('Settings module loaded, but its graphics-data getter is unavailable')
|
||||
end
|
||||
return false
|
||||
end
|
||||
if state.module == module then return true end
|
||||
|
||||
|
||||
local fps_text = 'UIText_Setting_Level_120FPS'
|
||||
local original = getter
|
||||
|
||||
module.get_graphics_data_by_type = function(...)
|
||||
local list = original(...)
|
||||
local ok, result, inserted = pcall(add_native_option, list, fps_text)
|
||||
if not ok and state.last_error ~= tostring(result) then
|
||||
state.last_error = tostring(result)
|
||||
log('could not add the native 120 FPS option: ' .. state.last_error)
|
||||
elseif ok and result and inserted and not state.row_added then
|
||||
state.row_added = true
|
||||
log('added the 120 FPS option to the native Graphics list')
|
||||
elseif not state.getter_probe_logged then
|
||||
state.getter_probe_logged = true
|
||||
local count = type(list) == 'table' and #list or -1
|
||||
log('Graphics getter called; returned ' .. type(list) .. ' with ' .. tostring(count) .. ' rows')
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
state.module = module
|
||||
state.original_getter = original
|
||||
log('ready; patched ' .. tostring(state.module_alias))
|
||||
patch_native_data(module)
|
||||
return true
|
||||
end
|
||||
|
||||
local function tick()
|
||||
local now = Unity.Time.unscaledTime
|
||||
if now < state.next_check then return end
|
||||
state.next_check = now + 0.5
|
||||
|
||||
local ok, result = pcall(install)
|
||||
if not ok then
|
||||
if state.last_error ~= tostring(result) then
|
||||
state.last_error = tostring(result)
|
||||
log('waiting for the native Graphics settings: ' .. state.last_error)
|
||||
end
|
||||
return
|
||||
end
|
||||
if result then
|
||||
remove_callback(state.callback)
|
||||
state.callback = nil
|
||||
end
|
||||
end
|
||||
|
||||
if not state.module and not state.callback then
|
||||
state.callback = tick
|
||||
local ok = add_callback(state.callback)
|
||||
if not ok then
|
||||
state.callback = nil
|
||||
log('could not register the Graphics settings hook')
|
||||
end
|
||||
end
|
||||
|
||||
if not state.module then
|
||||
tick()
|
||||
end
|
||||
end
|
||||
+158
-14
@@ -33,6 +33,9 @@ local function rainbow_watermark(now)
|
||||
end
|
||||
|
||||
local function show_ui(msg)
|
||||
if msg:find('[FreeCam:', 1, true) or msg:find('[FaceCam:', 1, true) or msg:find('FaceCam:', 1, true) then
|
||||
_G.__FC_STATUS_UNTIL = (Time.unscaledTime or 0) + 3
|
||||
end
|
||||
pcall(function()
|
||||
local obj = GameObject.Find("VersionText")
|
||||
if obj then
|
||||
@@ -62,11 +65,101 @@ local function get_active_cam()
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Resolve the character through the active camera's own follow target. A
|
||||
-- humanoid head bone gives a model-relative point as the character moves.
|
||||
-- Do not guess a world position if the Client has no usable target.
|
||||
-- Resolve the controlled member or current battle actor through the Client's
|
||||
-- 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 manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
||||
local field = typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase', flags)
|
||||
local phase = manager and field and field:GetValue(manager)
|
||||
if not phase then return nil, nil, nil, false end
|
||||
local entity, mode
|
||||
if typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then
|
||||
local instance = phase.BattleInstanceRef
|
||||
mode = instance and instance.TurnBasedGameModeRef
|
||||
entity = mode and mode.CurrentTurnOwnerEntity
|
||||
-- Inserted attacks can act outside the turn owner's turn. Prefer the
|
||||
-- most recently attacking entity while the Client reports one.
|
||||
if mode then
|
||||
local attackers = mode:GetAttackingEntityList()
|
||||
if attackers and attackers.Count > 0 then entity = attackers[attackers.Count - 1] end
|
||||
end
|
||||
elseif phase:GetType().Name == 'AdventurePhase' then
|
||||
entity = phase.CurrentPlayingTeamMember
|
||||
else
|
||||
return nil, nil, nil, false
|
||||
end
|
||||
if not entity then return nil, nil, nil, true end
|
||||
local function model_for(candidate)
|
||||
local ok, result = pcall(function()
|
||||
local component = candidate and candidate:GetComponent(typeof(CS.RPG.GameCore.CharacterModelComponent))
|
||||
return component and component:GetModelGO()
|
||||
end)
|
||||
if ok then return result end
|
||||
return nil
|
||||
end
|
||||
local model = model_for(entity)
|
||||
-- In a transformed turn the Client can report a model-less turn owner;
|
||||
-- the live battle's MVP entity points to the visible transformed actor.
|
||||
if not model and mode then
|
||||
local fallback = mode.CurrentMVPEntity
|
||||
local fallbackModel = model_for(fallback)
|
||||
if fallbackModel then entity, model = fallback, fallbackModel end
|
||||
end
|
||||
if not model then return nil, nil, nil, true end
|
||||
local cachedAlive, cachedActive = pcall(function()
|
||||
return _G.__FC_FACE_HEAD and _G.__FC_FACE_ROOT
|
||||
and _G.__FC_FACE_HEAD.gameObject.activeInHierarchy and _G.__FC_FACE_ROOT.gameObject.activeInHierarchy
|
||||
end)
|
||||
if _G.__FC_FACE_ENTITY == entity.RuntimeID and cachedAlive and cachedActive then
|
||||
return _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, entity.RuntimeID, true
|
||||
end
|
||||
local nodes = model:GetComponentsInChildren(typeof(CS.UnityEngine.Transform), true)
|
||||
for i = 0, nodes.Length - 1 do
|
||||
if nodes[i].name == 'Head_M' and nodes[i].gameObject.activeInHierarchy then
|
||||
return nodes[i], model.transform, entity.RuntimeID, true
|
||||
end
|
||||
end
|
||||
return nil, nil, nil, true
|
||||
end
|
||||
|
||||
-- Log only changes, so diagnostics stay useful during long battles.
|
||||
local function log_facecam_target(ok, head, root, id, tracked)
|
||||
local success, detail = pcall(function()
|
||||
if not ok then return 'resolve_error=' .. tostring(head) end
|
||||
return 'tracked=' .. tostring(tracked) .. ' entity=' .. tostring(id)
|
||||
.. ' model=' .. tostring(root and root.name)
|
||||
.. ' head=' .. tostring(head and head.name)
|
||||
.. ' head_parent=' .. tostring(head and head.parent and head.parent.name)
|
||||
.. ' head_active=' .. tostring(head and head.gameObject.activeInHierarchy)
|
||||
end)
|
||||
if not success then detail = 'diagnostic_error=' .. tostring(detail) end
|
||||
if _G.__FC_FACE_LAST_DIAGNOSTIC ~= detail then
|
||||
_G.__FC_FACE_LAST_DIAGNOSTIC = detail
|
||||
local message = '[GaC FaceCam] ' .. detail
|
||||
CS.UnityEngine.Debug.Log(message)
|
||||
local written, err = pcall(function()
|
||||
local dir = _G.__GAC_DIAGNOSTICS_DIR
|
||||
if not dir or dir == '' then error('server diagnostics directory unavailable') end
|
||||
CS.System.IO.Directory.CreateDirectory(dir)
|
||||
local path = CS.System.IO.Path.Combine(dir, 'facecam.log')
|
||||
CS.System.IO.File.AppendAllText(path, tostring(CS.System.DateTime.Now) .. ' ' .. message .. '\n')
|
||||
end)
|
||||
if not written and _G.__FC_FACE_FILE_ERROR ~= tostring(err) then
|
||||
_G.__FC_FACE_FILE_ERROR = tostring(err)
|
||||
CS.UnityEngine.Debug.Log('[GaC FaceCam] file log error: ' .. tostring(err))
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
local function facecam_target(cam)
|
||||
local ok, head, root = pcall(function()
|
||||
local battleHead, battleRoot, id, tracked = current_facecam_target()
|
||||
if tracked then
|
||||
_G.__FC_FACE_ENTITY = id
|
||||
return battleHead, battleRoot
|
||||
end
|
||||
local brain = cam:GetComponent(typeof(CS.Cinemachine.CinemachineBrain))
|
||||
local virtual = brain and brain.ActiveVirtualCamera
|
||||
local follow = virtual and virtual.Follow
|
||||
@@ -84,15 +177,47 @@ local function facecam_target(cam)
|
||||
end
|
||||
|
||||
local function update_facecam(cam, settings)
|
||||
local now = Time.unscaledTime or 0
|
||||
if now >= (_G.__FC_FACE_NEXT or 0) then
|
||||
_G.__FC_FACE_NEXT = now + 0.15
|
||||
local ok, head, root, id, tracked = pcall(current_facecam_target)
|
||||
log_facecam_target(ok, head, root, id, tracked)
|
||||
if ok and tracked and head and root then
|
||||
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT, _G.__FC_FACE_ENTITY = head, root, id
|
||||
end
|
||||
end
|
||||
local head, root = _G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT
|
||||
if not head or not root or not head.gameObject.activeInHierarchy then return false end
|
||||
local distance = tonumber(settings.distance) or 2
|
||||
local alive, active = pcall(function() return head and root and head.gameObject.activeInHierarchy end)
|
||||
if not alive or not active then
|
||||
-- Character transformations can briefly unload a model while keeping
|
||||
-- the same entity ID. Give the replacement time to become available.
|
||||
_G.__FC_FACE_MISSING_SINCE = _G.__FC_FACE_MISSING_SINCE or now
|
||||
return now - _G.__FC_FACE_MISSING_SINCE < 2
|
||||
end
|
||||
_G.__FC_FACE_MISSING_SINCE = nil
|
||||
local distance = _G.__FC_FACE_DISTANCE or tonumber(settings.distance) or 2
|
||||
local scroll = Input.mouseScrollDelta.y
|
||||
if scroll ~= 0 then distance = distance - scroll * 0.2 end
|
||||
distance = math.max(0.5, math.min(5, distance))
|
||||
_G.__FC_FACE_DISTANCE = distance
|
||||
if scroll ~= 0 then
|
||||
show_ui('<color=#00FF88><b>[FaceCam: ON]</b> Distance: ' .. string.format('%.1f', distance) .. '</color>')
|
||||
end
|
||||
local target = head.position
|
||||
local direction = root.forward
|
||||
local position = target + direction * distance
|
||||
cam.transform.position = position
|
||||
cam.transform.rotation = CS.UnityEngine.Quaternion.LookRotation(target - position, Vector3.up)
|
||||
local anchor = _G.__FC_FACE_ANCHOR
|
||||
if not anchor or anchor.transform.parent ~= head then
|
||||
if anchor then CS.UnityEngine.Object.Destroy(anchor) end
|
||||
anchor = GameObject('GaC_FaceCamAnchor')
|
||||
anchor.transform:SetParent(head, false)
|
||||
_G.__FC_FACE_ANCHOR = anchor
|
||||
end
|
||||
-- The observed game rig uses Head_M's local up axis as its facial
|
||||
-- direction; model-root forward does not follow the animated pose.
|
||||
local direction = head.up
|
||||
anchor.transform.position = target + direction * distance
|
||||
anchor.transform.rotation = CS.UnityEngine.Quaternion.LookRotation(target - anchor.transform.position, Vector3.up)
|
||||
cam.transform.position = anchor.transform.position
|
||||
cam.transform.rotation = anchor.transform.rotation
|
||||
_G.__FC_POS = cam.transform.position
|
||||
_G.__FC_EULER = cam.transform.eulerAngles
|
||||
return true
|
||||
@@ -153,7 +278,8 @@ local function update_mouse_gate()
|
||||
local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local phase = manager and gate.phaseField:GetValue(manager)
|
||||
local inBattle = phase and typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase)
|
||||
local wanted = _G.__FREECAM_ENABLED and inBattle
|
||||
local cameraSettings = _G.__GAC_CAMERA_SETTINGS
|
||||
local wanted = _G.__FREECAM_ENABLED and inBattle and (not cameraSettings or cameraSettings.mode ~= 'facecam')
|
||||
-- Releasing Freecam while holding RMB must not become a battle click.
|
||||
if not wanted then
|
||||
if gate.active and not Input.GetMouseButton(0) and not Input.GetMouseButton(1) then
|
||||
@@ -233,6 +359,7 @@ function _G.__FREECAM_TICK()
|
||||
if _G.__FREECAM_ENABLED then
|
||||
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam", distance=2}
|
||||
if settings.mode == "facecam" then
|
||||
_G.__FC_FACE_DISTANCE = tonumber(settings.distance) or 2
|
||||
local head, root
|
||||
if cam then head, root = facecam_target(cam) end
|
||||
if not head or not root then
|
||||
@@ -254,16 +381,22 @@ function _G.__FREECAM_TICK()
|
||||
show_ui("<color=#00FF88><b>[FreeCam: ON]</b> Speed: " .. string.format("%.1f", _G.__FREECAM_SPEED) .. " | Arrows: Fly | PgUp/PgDn: Up/Down | RMB: Look</color>")
|
||||
end
|
||||
else
|
||||
local settings = _G.__GAC_CAMERA_SETTINGS or {mode="freecam"}
|
||||
if cam then
|
||||
set_cinemachine_enabled(cam, true)
|
||||
end
|
||||
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = nil, nil
|
||||
show_ui(rainbow_watermark(Time.unscaledTime or 0))
|
||||
_G.__FC_FACE_ENTITY = nil
|
||||
_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'
|
||||
show_ui('<color=#00FF88><b>[' .. label .. ': OFF]</b></color>')
|
||||
end
|
||||
end
|
||||
|
||||
if not _G.__FREECAM_ENABLED then
|
||||
local now = Time.unscaledTime or 0
|
||||
local now = Time.unscaledTime or 0
|
||||
if now >= (_G.__FC_STATUS_UNTIL or 0) then
|
||||
if now >= (_G.__FC_WATERMARK_NEXT or 0) then
|
||||
_G.__FC_WATERMARK_NEXT = now + 0.05
|
||||
show_ui(rainbow_watermark(now))
|
||||
@@ -285,6 +418,10 @@ function _G.__FREECAM_TICK()
|
||||
set_cinemachine_enabled(cam, true)
|
||||
_G.__FREECAM_ENABLED = false
|
||||
_G.__FC_FACE_HEAD, _G.__FC_FACE_ROOT = nil, nil
|
||||
_G.__FC_FACE_ENTITY = nil
|
||||
_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
|
||||
show_ui("<color=#FFAA44>FaceCam: character unavailable</color>")
|
||||
end
|
||||
return
|
||||
@@ -313,9 +450,11 @@ function _G.__FREECAM_TICK()
|
||||
|
||||
-- Raw Unity mouse input remains available to the camera while
|
||||
-- InControl battle clicks are gated separately below.
|
||||
local cameraAdjusted = false
|
||||
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)
|
||||
end
|
||||
@@ -352,6 +491,11 @@ function _G.__FREECAM_TICK()
|
||||
|
||||
if move.sqrMagnitude > 0.0001 then
|
||||
_G.__FC_POS = _G.__FC_POS + move.normalized * (_G.__FREECAM_SPEED * dt)
|
||||
cameraAdjusted = true
|
||||
end
|
||||
if cameraAdjusted and now >= (_G.__FC_INDICATOR_NEXT or 0) then
|
||||
_G.__FC_INDICATOR_NEXT = now + 0.1
|
||||
show_ui('<color=#00FF88><b>[FreeCam: ON]</b> Speed: ' .. string.format('%.1f', _G.__FREECAM_SPEED) .. '</color>')
|
||||
end
|
||||
|
||||
-- Enforce locked free camera transform EVERY frame
|
||||
@@ -378,7 +522,7 @@ if not _G.__FREECAM_REGISTERED then
|
||||
pcall(function() CS.UnityEngine.Application.onBeforeRender = CS.UnityEngine.Application.onBeforeRender + function() _G.__FREECAM_TICK() end end)
|
||||
end
|
||||
|
||||
if not _G.__FREECAM_ENABLED then
|
||||
if not _G.__FREECAM_ENABLED and (Time.unscaledTime or 0) >= (_G.__FC_STATUS_UNTIL or 0) then
|
||||
show_ui(rainbow_watermark(Time.unscaledTime or 0))
|
||||
end
|
||||
|
||||
|
||||
+61
-6
@@ -6,6 +6,22 @@ local GameObject = CS.UnityEngine.GameObject
|
||||
local CanvasGroup = CS.UnityEngine.CanvasGroup
|
||||
local Camera = CS.UnityEngine.Camera
|
||||
|
||||
-- The server supplies its installation's log directory in the heartbeat.
|
||||
local function diagnostic(message)
|
||||
CS.UnityEngine.Debug.Log(message)
|
||||
local ok, err = pcall(function()
|
||||
local dir = _G.__GAC_DIAGNOSTICS_DIR
|
||||
if not dir or dir == '' then error('server diagnostics directory unavailable') end
|
||||
CS.System.IO.Directory.CreateDirectory(dir)
|
||||
local path = CS.System.IO.Path.Combine(dir, 'hide-ui.log')
|
||||
CS.System.IO.File.AppendAllText(path, tostring(CS.System.DateTime.Now) .. ' ' .. message .. '\n')
|
||||
end)
|
||||
if not ok and _G.__HIDE_UI_FILE_ERROR ~= tostring(err) then
|
||||
_G.__HIDE_UI_FILE_ERROR = tostring(err)
|
||||
CS.UnityEngine.Debug.Log('[GaC HideUI] file log error: ' .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
-- Names observed under UIRoot in this Client, plus the existing battle HUD.
|
||||
-- Never target an ancestor shared by HUD and character/reward dialogs.
|
||||
local HUD_PATHS = {
|
||||
@@ -58,8 +74,9 @@ _G.__HIDE_UI_ENABLED = _G.__HIDE_UI_ENABLED or false
|
||||
_G.__HIDE_UI_TARGETS = _G.__HIDE_UI_TARGETS or {}
|
||||
|
||||
local function restore_hud()
|
||||
local count, errors = 0, 0
|
||||
for id, state in pairs(_G.__HIDE_UI_TARGETS) do
|
||||
pcall(function()
|
||||
local ok = pcall(function()
|
||||
if state.group then
|
||||
if state.added then
|
||||
CS.UnityEngine.Object.Destroy(state.group)
|
||||
@@ -70,8 +87,11 @@ local function restore_hud()
|
||||
end
|
||||
end
|
||||
end)
|
||||
count = count + 1
|
||||
if not ok then errors = errors + 1 end
|
||||
_G.__HIDE_UI_TARGETS[id] = nil
|
||||
end
|
||||
diagnostic('[GaC HideUI] restored=' .. count .. ' errors=' .. errors)
|
||||
end
|
||||
|
||||
local function protected_name(name)
|
||||
@@ -91,6 +111,9 @@ local function hide_object(obj)
|
||||
state = {group=group, added=added, alpha=group.alpha,
|
||||
raycast=group.blocksRaycasts, interactable=group.interactable}
|
||||
_G.__HIDE_UI_TARGETS[id] = state
|
||||
if _G.__HIDE_UI_SCAN_DIAG then
|
||||
_G.__HIDE_UI_SCAN_DIAG.added = _G.__HIDE_UI_SCAN_DIAG.added + 1
|
||||
end
|
||||
end
|
||||
state.group.alpha = 0
|
||||
state.group.blocksRaycasts = false
|
||||
@@ -166,7 +189,14 @@ end
|
||||
|
||||
local function hide_hud()
|
||||
for _, path in ipairs(HUD_PATHS) do
|
||||
pcall(function() hide_hud_tree(GameObject.Find(path)) end)
|
||||
local ok, err = pcall(function()
|
||||
local obj = GameObject.Find(path)
|
||||
if obj and obj.activeInHierarchy then
|
||||
table.insert(_G.__HIDE_UI_SCAN_DIAG.paths, path)
|
||||
end
|
||||
hide_hud_tree(obj)
|
||||
end)
|
||||
if not ok then table.insert(_G.__HIDE_UI_SCAN_DIAG.errors, path .. ': ' .. tostring(err)) end
|
||||
end
|
||||
local ok, err = pcall(function()
|
||||
local flags = CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags), 60)
|
||||
@@ -176,6 +206,7 @@ local function hide_hud()
|
||||
or typeof(CS.RPG.Client.BattleGamePhase):GetField("_HudPage", flags)
|
||||
local manager = CS.RPG.Client.GlobalVars.s_GamePhaseManager
|
||||
local phase = manager and _G.__HIDE_UI_PHASE_FIELD:GetValue(manager)
|
||||
_G.__HIDE_UI_SCAN_DIAG.phase = phase and tostring(phase:GetType().Name) or 'none'
|
||||
if phase and typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then
|
||||
local hud = _G.__HIDE_UI_BATTLE_HUD_FIELD:GetValue(phase)
|
||||
if hud and hud.Trans then hide_hud_tree(hud.Trans.gameObject) end
|
||||
@@ -189,13 +220,14 @@ local function hide_hud()
|
||||
end)
|
||||
if not ok and _G.__HIDE_UI_LAST_ERROR ~= tostring(err) then
|
||||
_G.__HIDE_UI_LAST_ERROR = tostring(err)
|
||||
CS.UnityEngine.Debug.Log("[GaC HideUI] Battle HUD: " .. tostring(err))
|
||||
diagnostic("[GaC HideUI] Battle HUD: " .. tostring(err))
|
||||
end
|
||||
pcall(function()
|
||||
local pagesOK, pagesErr = pcall(function()
|
||||
local ui = CS.RPG.Client.GlobalVars.s_UIManager
|
||||
local pages = ui:GetAllPageNames()
|
||||
for i = 0, pages.Count - 1 do
|
||||
local name = tostring(pages[i]); local lower = string.lower(name)
|
||||
table.insert(_G.__HIDE_UI_SCAN_DIAG.pages, name)
|
||||
local page = ui:GetPageByName(name)
|
||||
if page and page.ChildDialogs then
|
||||
for j = 0, page.ChildDialogs.Count - 1 do
|
||||
@@ -219,16 +251,18 @@ local function hide_hud()
|
||||
end
|
||||
hide_rank_controls(GameObject.Find("UIRoot/Page/AvatarMainPage(Clone)/Root/AvatarRankPanel"))
|
||||
end)
|
||||
if not pagesOK then table.insert(_G.__HIDE_UI_SCAN_DIAG.errors, 'pages: ' .. tostring(pagesErr)) end
|
||||
end
|
||||
|
||||
function _G.__HIDE_UI_TICK()
|
||||
pcall(function()
|
||||
local ok, err = pcall(function()
|
||||
local frame = Time.frameCount
|
||||
if _G.__HIDE_UI_LAST_FRAME == frame then return end
|
||||
_G.__HIDE_UI_LAST_FRAME = frame
|
||||
local toggled = Input.GetKeyDown(KeyCode.Backslash)
|
||||
if toggled then
|
||||
_G.__HIDE_UI_ENABLED = not _G.__HIDE_UI_ENABLED
|
||||
diagnostic('[GaC HideUI] enabled=' .. tostring(_G.__HIDE_UI_ENABLED))
|
||||
if not _G.__HIDE_UI_ENABLED then restore_hud() end
|
||||
end
|
||||
-- The page/child scan walks Unity UI trees, so keep it to once a second.
|
||||
@@ -237,10 +271,31 @@ function _G.__HIDE_UI_TICK()
|
||||
local now = Time.unscaledTime
|
||||
if toggled or now >= (_G.__HIDE_UI_NEXT_SCAN or 0) then
|
||||
_G.__HIDE_UI_NEXT_SCAN = now + 1.0
|
||||
hide_hud()
|
||||
local started = Time.realtimeSinceStartup
|
||||
local diag = {added=0, paths={}, pages={}, errors={}, phase='unknown'}
|
||||
_G.__HIDE_UI_SCAN_DIAG = diag
|
||||
local scanOK, scanErr = pcall(hide_hud)
|
||||
_G.__HIDE_UI_SCAN_DIAG = nil
|
||||
if not scanOK then table.insert(diag.errors, 'scan: ' .. tostring(scanErr)) end
|
||||
local elapsed = (Time.realtimeSinceStartup - started) * 1000
|
||||
local signature = diag.phase .. '|' .. table.concat(diag.pages, ',') .. '|'
|
||||
.. table.concat(diag.paths, ',') .. '|' .. table.concat(diag.errors, ',')
|
||||
if toggled or diag.added > 0 or #diag.errors > 0 or elapsed >= 20
|
||||
or signature ~= _G.__HIDE_UI_DIAG_SIGNATURE
|
||||
or now >= (_G.__HIDE_UI_DIAG_NEXT or 0) then
|
||||
_G.__HIDE_UI_DIAG_SIGNATURE = signature
|
||||
_G.__HIDE_UI_DIAG_NEXT = now + 30
|
||||
diagnostic(string.format('[GaC HideUI] scan_ms=%.2f phase=%s added=%d pages=[%s] paths=[%s] errors=[%s]',
|
||||
elapsed, diag.phase, diag.added, table.concat(diag.pages, ','),
|
||||
table.concat(diag.paths, ','), table.concat(diag.errors, ',')))
|
||||
end
|
||||
end
|
||||
end
|
||||
end)
|
||||
if not ok and _G.__HIDE_UI_TICK_ERROR ~= tostring(err) then
|
||||
_G.__HIDE_UI_TICK_ERROR = tostring(err)
|
||||
diagnostic('[GaC HideUI] tick error: ' .. tostring(err))
|
||||
end
|
||||
end
|
||||
|
||||
-- Restore targets from the previous policy (including VersionText) on upgrade.
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"sdk_port": 21000,
|
||||
"public_game_host": "127.0.0.1",
|
||||
"public_sdk_url": "http://127.0.0.1:21000",
|
||||
"game_clock": { "state_file": ".runtime/game-clock.json" },
|
||||
"lua_overlay": {
|
||||
"enabled": false
|
||||
},
|
||||
"battle_diagnostics": {
|
||||
"client_battle_type": "",
|
||||
"live_report_stale_seconds": 10
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
do if not _G.__gacAAPeakCache1 then _G.__gacAAPeakCache1=true
|
||||
local ok,e=pcall(function()
|
||||
local m=CS.RPG.Client.GlobalVars.s_ModuleManager.ChallengePeakModule
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local t=m:GetType();local fields=t:GetFields(flags)
|
||||
for i=0,fields.Length-1 do local f=fields[i];local v=f:GetValue(m);CS.UnityEngine.Debug.Log('[GAC_AA_CACHE] FIELD '..f.Name..' '..tostring(f.FieldType)..' '..tostring(v))end
|
||||
local methods=t:GetMethods(flags)
|
||||
for i=0,methods.Length-1 do local f=methods[i];if string.find(f.Name,'GetChallengePeakData') or string.find(f.Name,'CurrentPeak') or string.find(f.Name,'Clear') or string.find(f.Name,'Reset') then CS.UnityEngine.Debug.Log('[GAC_AA_CACHE] METHOD '..tostring(f)..' HANDLE '..tostring(f.MethodHandle.Value))end end
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_AA_CACHE] ERROR '..tostring(e))end
|
||||
end end
|
||||
@@ -0,0 +1,9 @@
|
||||
do if not _G.__gacAAStatusNow1 then _G.__gacAAStatusNow1=true
|
||||
local ok,e=pcall(function()
|
||||
local d=_G.__GAC_AA_ROUND_REFRESH
|
||||
local st=_G.__GAC_AA_ROUND_REFRESH_STATE
|
||||
local m=CS.RPG.Client.GlobalVars.s_ModuleManager.ChallengePeakModule
|
||||
CS.UnityEngine.Debug.Log('[GAC_AA_STATUS_NOW] current='..tostring(m and m.CurrentPeakGroupID)..' desired='..tostring(d and d.desired_group)..' deferred='..tostring(d and d.deferred)..' pending='..tostring(st and st.pending~=nil)..' error='..tostring(_G.__GAC_AA_ROUND_REFRESH_ERROR))
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_AA_STATUS_NOW] ERROR '..tostring(e))end
|
||||
end end
|
||||
@@ -0,0 +1,11 @@
|
||||
do if not _G.__gacScheduleAccess1 then _G.__gacScheduleAccess1=true
|
||||
local ok,e=pcall(function()
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local m=CS.RPG.Client.GlobalVars.s_ModuleManager.ChallengeModule
|
||||
local methods=m:GetType():GetMethods(flags)
|
||||
for i=0,methods.Length-1 do local x=methods[i];if string.find(x.Name,'GroupData')then CS.UnityEngine.Debug.Log('[GAC_SCHEDULE_ACCESS] '..tostring(x))end end
|
||||
local tm=CS.RPG.Client.GlobalVars.s_TimeManager
|
||||
if tm then local props=tm:GetType():GetProperties(flags);for i=0,props.Length-1 do local p=props[i];if p:GetIndexParameters().Length==0 and (string.find(p.Name,'Stamp') or string.find(p.Name,'Time')) then local ok,v=pcall(function()return p:GetValue(tm,nil)end);CS.UnityEngine.Debug.Log('[GAC_CLOCK_CLIENT] '..p.Name..' '..tostring(ok)..' '..tostring(v))end end end
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_SCHEDULE_ACCESS] ERROR '..tostring(e))end
|
||||
end end
|
||||
@@ -0,0 +1,18 @@
|
||||
do
|
||||
local s=_G.__gacScheduleGroup1 or {i=1};_G.__gacScheduleGroup1=s
|
||||
if s.i>3 then return end
|
||||
local k=s.i;s.i=k+1
|
||||
local ok,e=pcall(function()
|
||||
local m=CS.RPG.Client.GlobalVars.s_ModuleManager.ChallengeModule
|
||||
local kind=CS.System.Enum.ToObject(typeof(CS.RPG.GameCore.ChallengeGroupType),k)
|
||||
local g=m:GetLatestScheduleChallengeGroupDataByGroupType(kind)
|
||||
if not g then return end
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local props=g:GetType():GetProperties(flags)
|
||||
for i=0,props.Length-1 do local p=props[i];if p:GetIndexParameters().Length==0 then
|
||||
local n=string.lower(p.Name)
|
||||
if string.find(n,'schedule') or string.find(n,'time') or string.find(n,'begin') or string.find(n,'end') or string.find(n,'row') or string.find(n,'meta') then local ok,v=pcall(function()return p:GetValue(g,nil)end);CS.UnityEngine.Debug.Log('[GAC_GROUP_SCHEDULE] '..k..' '..p.Name..' '..tostring(p.PropertyType)..' '..tostring(ok)..' '..tostring(v));if ok and v and (string.find(n,'schedule') or string.find(n,'row')) then local j,txt=pcall(function()return CS.Newtonsoft.Json.JsonConvert.SerializeObject(v)end);if j then CS.UnityEngine.Debug.Log('[GAC_GROUP_SCHEDULE_JSON] '..k..' '..p.Name..' '..txt)end end end
|
||||
end end
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_GROUP_SCHEDULE] ERROR '..tostring(e))end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
do
|
||||
if not _G.__gacScheduleSurface1 then
|
||||
_G.__gacScheduleSurface1=true
|
||||
local ok,e=pcall(function()
|
||||
local m=CS.RPG.Client.GlobalVars.s_ModuleManager.ChallengeModule
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local methods=m:GetType():GetMethods(flags)
|
||||
for i=0,methods.Length-1 do local x=methods[i];if string.find(string.lower(x.Name),'schedule') or string.find(string.lower(x.Name),'time') or string.find(string.lower(x.Name),'open') then CS.UnityEngine.Debug.Log('[GAC_SCHEDULE_SURFACE] '..tostring(x)) end end
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_SCHEDULE_SURFACE] ERROR '..tostring(e)) end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Bounded read-only schedule probe; remove after capture.
|
||||
do
|
||||
local s=_G.__gacEndgameTime8 or {i=1};_G.__gacEndgameTime8=s
|
||||
local ids={100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,900,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,2001,2002,2003,2004,2005,2006,2007,2008,2009,2010,2011,2012,2013,2014,2015,2016,2017,2018,2019,2020,2021,2022,2023,2024,2025,2026,2027,2028,3001,3002,3003,3004,3005,3006,3007,3008,3009,3010,3011,3012,3013,3014,3015,3016,3017,3018,3019,3020,3021,3022,3023}
|
||||
if s.i>#ids then return end
|
||||
for count=1,8 do if s.i>#ids then return end;local id=ids[s.i];s.i=s.i+1
|
||||
local ok,e=pcall(function()
|
||||
local row=CS.RPG.GameCore.ChallengeGroupConfigExcelTable.GetData(id)
|
||||
local kind=CS.System.Convert.ToInt32(row.ChallengeGroupType);if row and row.ScheduleDataID~=0 and kind>=1 and kind<=3 then local tables={CS.RPG.GameCore.ScheduleDataChallengeMazeConfigExcelTable,CS.RPG.GameCore.ScheduleDataChallengeStoryConfigExcelTable,CS.RPG.GameCore.ScheduleDataChallengeBossConfigExcelTable};local schedule=tables[kind].GetData(row.ScheduleDataID);CS.UnityEngine.Debug.Log('[GAC_ENDGAME_SCHEDULE] '..id..' '..CS.Newtonsoft.Json.JsonConvert.SerializeObject(schedule)) end
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_ENDGAME_TIME] ERROR '..tostring(e));s.i=#ids+1 end
|
||||
end end
|
||||
@@ -0,0 +1,22 @@
|
||||
do
|
||||
local s=_G.__gacWindowCapture2 or {i=1};_G.__gacWindowCapture2=s
|
||||
local ids={100,101,102,103,104,105,106,107,108,109,110,111,112,113,114,115,116,117,118,119,900,1001,1002,1003,1004,1005,1006,1007,1008,1009,1010,1011,1012,1013,1014,1015,1016,1017,1018,1019,1020,1021,1022,1023,1024,1025,1026,1027,1028,1029,1030,1031,1032,1033,1034,1035,1036,1037,2001,2002,2003,2004,2005,2006,2007,2008,2009,2010,2011,2012,2013,2014,2015,2016,2017,2018,2019,2020,2021,2022,2023,2024,2025,2026,2027,2028,3001,3002,3003,3004,3005,3006,3007,3008,3009,3010,3011,3012,3013,3014,3015,3016,3017,3018,3019,3020,3021,3022,3023}
|
||||
if s.i>#ids then return end
|
||||
local m=CS.RPG.Client.GlobalVars.s_ModuleManager.ChallengeModule
|
||||
for n=1,8 do
|
||||
if s.i>#ids then return end
|
||||
local id=ids[s.i];s.i=s.i+1
|
||||
local ok,e=pcall(function()
|
||||
local g=m:GetChallengeGroupData(id)
|
||||
if g and g.IsScheduleChallenge then
|
||||
CS.UnityEngine.Debug.Log('[GAC_ENDGAME_WINDOW] '..id..' '..g.BeginTimeStamp..' '..g.EndTimeStamp)
|
||||
if id==ids[#ids] then
|
||||
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
|
||||
local props=g:GetType():GetProperties(flags)
|
||||
for i=0,props.Length-1 do local p=props[i];if p.PropertyType==typeof(CS.System.Boolean) and p:GetIndexParameters().Length==0 then local ok,v=pcall(function()return p:GetValue(g,nil)end);CS.UnityEngine.Debug.Log('[GAC_GROUP_BOOL] '..p.Name..' '..tostring(ok)..' '..tostring(v))end end
|
||||
end
|
||||
end
|
||||
end)
|
||||
if not ok then CS.UnityEngine.Debug.Log('[GAC_ENDGAME_WINDOW] ERROR '..id..' '..tostring(e));s.i=#ids+1;return end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install or restore a staged Lua selector without deleting source bundles."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def selector_digest(data):
|
||||
if len(data) != 66 or data[:4] != b"SRMI":
|
||||
raise ValueError("unsupported LuaV selector")
|
||||
return b"".join(data[i:i + 4][::-1] for i in range(28, 44, 4)).hex()
|
||||
|
||||
|
||||
def atomic_copy(source, target):
|
||||
fd, name = tempfile.mkstemp(prefix=".gacy-luav-", dir=target.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, source.open("rb") as inp:
|
||||
shutil.copyfileobj(inp, out)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(name, target)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def install(source, stage, backup):
|
||||
source, stage, backup = source.resolve(strict=True), stage.resolve(strict=True), backup.resolve()
|
||||
if source == stage or source in stage.parents or backup == source or source in backup.parents:
|
||||
raise ValueError("stage and backup must be outside the installation")
|
||||
current = source / "M_LuaV.bytes"
|
||||
current_bytes = current.read_bytes()
|
||||
old_digest = selector_digest(current_bytes)
|
||||
if not (source / ("LuaV_" + old_digest + ".bytes")).is_file():
|
||||
raise ValueError("original selected index is unavailable")
|
||||
new_selector = stage / "M_LuaV.bytes"
|
||||
new_digest = selector_digest(new_selector.read_bytes())
|
||||
index = stage / ("LuaV_" + new_digest + ".bytes")
|
||||
if hashlib.md5(index.read_bytes()).hexdigest() != new_digest:
|
||||
raise ValueError("staged index digest differs")
|
||||
blocks = [p for p in stage.glob("*.bytes") if p.name != "M_LuaV.bytes" and not p.name.startswith("LuaV_")]
|
||||
if len(blocks) != 1 or hashlib.md5(blocks[0].read_bytes()).hexdigest() != blocks[0].stem:
|
||||
raise ValueError("staged block digest differs")
|
||||
if bytes.fromhex(blocks[0].stem) not in index.read_bytes():
|
||||
raise ValueError("staged index does not reference staged block")
|
||||
backup.mkdir(parents=True, exist_ok=True)
|
||||
backup_file = backup / ("M_LuaV-" + old_digest + ".bytes")
|
||||
if backup_file.exists() and backup_file.read_bytes() != current_bytes:
|
||||
raise ValueError("existing selector backup differs")
|
||||
if not backup_file.exists():
|
||||
atomic_copy(current, backup_file)
|
||||
for item in (blocks[0], index):
|
||||
dst = source / item.name
|
||||
if dst.exists():
|
||||
if hashlib.md5(dst.read_bytes()).hexdigest() != hashlib.md5(item.read_bytes()).hexdigest():
|
||||
raise ValueError("existing content-addressed file differs")
|
||||
else:
|
||||
atomic_copy(item, dst)
|
||||
if current.read_bytes() != current_bytes:
|
||||
raise ValueError("selector changed during installation")
|
||||
atomic_copy(new_selector, current)
|
||||
if current.read_bytes() != new_selector.read_bytes():
|
||||
raise ValueError("installed selector did not verify")
|
||||
print("Installed staged Lua selector; original backup:", backup_file)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--restore", action="store_true", help="restore the backup selector")
|
||||
p.add_argument("source", type=Path)
|
||||
p.add_argument("stage", type=Path)
|
||||
p.add_argument("backup", type=Path)
|
||||
args = p.parse_args()
|
||||
if args.restore:
|
||||
source, stage, backup = args.source.resolve(strict=True), args.stage.resolve(strict=True), args.backup.resolve(strict=True)
|
||||
if source == stage or source in stage.parents or source == backup or source in backup.parents:
|
||||
raise ValueError("stage and backup must be outside the installation")
|
||||
target = source / "M_LuaV.bytes"
|
||||
if target.read_bytes() != (stage / "M_LuaV.bytes").read_bytes():
|
||||
raise ValueError("current selector no longer matches this staged patch")
|
||||
candidates = list(backup.glob("M_LuaV-*.bytes"))
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("backup selector is missing or ambiguous")
|
||||
original_digest = selector_digest(candidates[0].read_bytes())
|
||||
if candidates[0].name != "M_LuaV-" + original_digest + ".bytes" or not (source / ("LuaV_" + original_digest + ".bytes")).is_file():
|
||||
raise ValueError("backup selector or original index does not match")
|
||||
atomic_copy(candidates[0], target)
|
||||
print("Restored original Lua selector")
|
||||
else:
|
||||
install(args.source, args.stage, args.backup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a staged Lua bundle rewrite; never modify the source installation.
|
||||
|
||||
The caller supplies the Client's Lua/Windows directory, output directory and
|
||||
two exact asset paths. This handles the observed LuaV big-endian index and
|
||||
SRMI digest format only; it refuses ambiguous input instead of guessing.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def md5(data):
|
||||
return hashlib.md5(data).digest()
|
||||
|
||||
|
||||
def replace_once(buf, old, new):
|
||||
if len(old) >= 128 or len(new) >= 128:
|
||||
raise ValueError("only single-byte Lua string lengths are supported")
|
||||
original = bytes([len(old)]) + old
|
||||
replacement = bytes([len(new)]) + new
|
||||
if buf.count(original) != 1:
|
||||
raise ValueError("the exact length-prefixed string must occur once")
|
||||
index = buf.index(original)
|
||||
return buf[:index] + replacement + buf[index + len(original):], index
|
||||
|
||||
|
||||
def stage(source, output, old, new):
|
||||
source, output = source.resolve(strict=True), output.resolve()
|
||||
if output == source or source in output.parents:
|
||||
raise ValueError("output must be outside the source installation")
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ValueError("output must be empty")
|
||||
selector = (source / "M_LuaV.bytes").read_bytes()
|
||||
if len(selector) != 66 or selector[:4] != b"SRMI":
|
||||
raise ValueError("unsupported LuaV selector")
|
||||
digest = b"".join(selector[i:i + 4][::-1] for i in range(28, 44, 4))
|
||||
index_path = source / ("LuaV_" + digest.hex() + ".bytes")
|
||||
index = bytearray(index_path.read_bytes())
|
||||
if md5(index) != digest or struct.unpack_from("<I", selector, 44)[0] != len(index):
|
||||
raise ValueError("LuaV selector digest or size does not match")
|
||||
if len(index) < 16 or struct.unpack_from(">II", index, 0) != (255, 4):
|
||||
raise ValueError("unsupported LuaV index header")
|
||||
blocks, total_modules = struct.unpack_from(">II", index, 8)
|
||||
pos, entries_seen, matches = 16, 0, []
|
||||
old, new = old.encode("utf-8"), new.encode("utf-8")
|
||||
for _ in range(blocks):
|
||||
if pos + 36 > len(index):
|
||||
raise ValueError("truncated LuaV block header")
|
||||
block_digest = bytes(index[pos + 8:pos + 24])
|
||||
reserved, size, count = struct.unpack_from(">III", index, pos + 24)
|
||||
table = pos + 36
|
||||
end = table + count * 16
|
||||
if reserved != 0 or end > len(index):
|
||||
raise ValueError("unsupported LuaV block table")
|
||||
block_path = source / (block_digest.hex() + ".bytes")
|
||||
data = block_path.read_bytes()
|
||||
if len(data) != size or md5(data) != block_digest:
|
||||
raise ValueError("Lua block digest or size differs")
|
||||
entries_seen += count
|
||||
needle = bytes([len(old)]) + old
|
||||
if data.count(needle):
|
||||
if data.count(needle) != 1:
|
||||
raise ValueError("ambiguous path within Lua block")
|
||||
location = data.index(needle)
|
||||
entries = [struct.unpack_from(">QII", index, table + 16 * i) for i in range(count)]
|
||||
containing = [(i, off, length) for i, (_, length, off) in enumerate(entries)
|
||||
if off <= location and location + len(needle) <= off + length]
|
||||
if len(containing) != 1:
|
||||
raise ValueError("Lua module index does not uniquely cover the string")
|
||||
matches.append((pos, table, data, entries, containing[0], location))
|
||||
if bytes(index[end:end + 3]) != b"\x00\x00\x80":
|
||||
raise ValueError("unsupported LuaV block trailer")
|
||||
pos = end + 3
|
||||
if entries_seen != total_modules or pos != len(index) or len(matches) != 1:
|
||||
raise ValueError("LuaV index size/count or matching block differs")
|
||||
pos, table, data, entries, (module, module_offset, module_len), location = matches[0]
|
||||
rewritten, _ = replace_once(data, old, new)
|
||||
delta = len(rewritten) - len(data)
|
||||
for i, (_, length, offset) in enumerate(entries):
|
||||
if i == module:
|
||||
length += delta
|
||||
elif offset >= module_offset + module_len:
|
||||
offset += delta
|
||||
elif offset < module_offset + module_len and offset + length > module_offset:
|
||||
raise ValueError("overlapping Lua module entries")
|
||||
if length < 0 or offset < 0 or offset + length > len(rewritten):
|
||||
raise ValueError("rewritten Lua module bounds invalid")
|
||||
struct.pack_into(">II", index, table + 16 * i + 8, length, offset)
|
||||
index[pos + 8:pos + 24] = md5(rewritten)
|
||||
struct.pack_into(">I", index, pos + 28, len(rewritten))
|
||||
new_index_digest = md5(index)
|
||||
updated_selector = bytearray(selector)
|
||||
for i in range(4):
|
||||
updated_selector[28 + i * 4:32 + i * 4] = new_index_digest[i * 4:i * 4 + 4][::-1]
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / (md5(rewritten).hex() + ".bytes")).write_bytes(rewritten)
|
||||
(output / ("LuaV_" + new_index_digest.hex() + ".bytes")).write_bytes(index)
|
||||
(output / "M_LuaV.bytes").write_bytes(updated_selector)
|
||||
return {"old_block_md5": md5(data).hex(), "new_block_md5": md5(rewritten).hex(),
|
||||
"old_index_md5": digest.hex(), "new_index_md5": new_index_digest.hex(),
|
||||
"module_offset": module_offset, "module_old_size": module_len,
|
||||
"module_new_size": module_len + delta, "string_offset": location}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("old_path")
|
||||
parser.add_argument("new_path")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(stage(args.source, args.output, args.old_path, args.new_path), indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage full shipped ASB manifests for early startup; retain originals.
|
||||
|
||||
Observed SRMI selector, SRAM v5 manifest and SRBM v1 block-table formats.
|
||||
This changes startup availability only; it does not select a login scene.
|
||||
All installation paths are explicit arguments. Restore refuses unrelated edits.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from install_baked_lua_stage import atomic_copy, selector_digest
|
||||
|
||||
|
||||
KINDS = ('AsbV', 'BlockV')
|
||||
|
||||
|
||||
def selected(root, name):
|
||||
selector = root / ('M_' + name + '.bytes')
|
||||
raw = selector.read_bytes()
|
||||
digest = selector_digest(raw)
|
||||
data = root / (name + '_' + digest + '.bytes')
|
||||
content = data.read_bytes()
|
||||
if hashlib.md5(content).hexdigest() != digest or struct.unpack_from('<I', raw, 44)[0] != len(content):
|
||||
raise ValueError('selector size or digest mismatch: ' + name)
|
||||
return selector, raw, data, content
|
||||
|
||||
|
||||
def stage(source, output):
|
||||
source, output = source.resolve(strict=True), output.resolve()
|
||||
if source == output or source in output.parents:
|
||||
raise ValueError('stage must be outside installation')
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ValueError('stage must be empty')
|
||||
prepared = []
|
||||
for kind in KINDS:
|
||||
_, old, _, before = selected(source, 'Start_' + kind)
|
||||
_, full, path, content = selected(source, kind)
|
||||
if old[:28] != full[:28] or before[:16] != content[:16]:
|
||||
raise ValueError('startup and full manifest schema/build differ')
|
||||
if kind == 'BlockV':
|
||||
if content[:8] != b'SRBM\x00\x01\x00\x01':
|
||||
raise ValueError('unsupported block table')
|
||||
offset, count, stride = struct.unpack_from('<III', content, 16)
|
||||
if offset != 28 or stride != 24 or len(content) != offset + count * stride:
|
||||
raise ValueError('unsupported block table bounds')
|
||||
for i in range(count):
|
||||
row = content[offset+i*stride:offset+(i+1)*stride]
|
||||
digest = b''.join(row[j:j+4][::-1] for j in range(0, 16, 4)).hex()
|
||||
if not (source / (digest + '.block')).is_file():
|
||||
raise ValueError('missing referenced block: ' + digest)
|
||||
prepared.append((kind, full, path, content))
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for kind, selector, path, content in prepared:
|
||||
(output / ('Start_' + path.name)).write_bytes(content)
|
||||
(output / ('M_Start_' + kind + '.bytes')).write_bytes(selector)
|
||||
print('Staged full manifests; all referenced block files exist')
|
||||
|
||||
|
||||
def apply(source, staged, backup, restore=False):
|
||||
source, staged, backup = source.resolve(strict=True), staged.resolve(strict=True), backup.resolve()
|
||||
if any(p == source or source in p.parents for p in (staged, backup)):
|
||||
raise ValueError('stage and backup must be outside installation')
|
||||
entries = []
|
||||
for kind in KINDS:
|
||||
name = 'M_Start_' + kind + '.bytes'
|
||||
current = source / name
|
||||
_, expected, blob, _ = selected(staged, 'Start_' + kind)
|
||||
saved = backup / name
|
||||
if restore:
|
||||
if current.read_bytes() != expected:
|
||||
raise ValueError('current selector is not this overlay: ' + name)
|
||||
selected_backup = saved.read_bytes()
|
||||
original = source / ('Start_' + kind + '_' + selector_digest(selected_backup) + '.bytes')
|
||||
if not original.is_file():
|
||||
raise ValueError('original manifest missing')
|
||||
else:
|
||||
selected(source, 'Start_' + kind)
|
||||
if saved.exists() and saved.read_bytes() != current.read_bytes():
|
||||
raise ValueError('existing backup differs')
|
||||
entries.append((current, saved, staged / name, blob))
|
||||
backup.mkdir(parents=True, exist_ok=True)
|
||||
for current, saved, selector, blob in entries:
|
||||
if restore:
|
||||
atomic_copy(saved, current)
|
||||
else:
|
||||
if not saved.exists():
|
||||
atomic_copy(current, saved)
|
||||
atomic_copy(blob, source / blob.name)
|
||||
atomic_copy(selector, current)
|
||||
if current.read_bytes() != (saved if restore else selector).read_bytes():
|
||||
raise ValueError('selector write verification failed')
|
||||
print('Restored original startup manifests' if restore else 'Installed startup manifest overlay; originals retained')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('mode', choices=('stage', 'install', 'restore'))
|
||||
parser.add_argument('source', type=Path)
|
||||
parser.add_argument('staged', type=Path)
|
||||
parser.add_argument('--backup', type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.mode == 'stage':
|
||||
stage(args.source, args.staged)
|
||||
else:
|
||||
if args.backup is None:
|
||||
parser.error('--backup is required for install/restore')
|
||||
apply(args.source, args.staged, args.backup, args.mode == 'restore')
|
||||
@@ -0,0 +1,58 @@
|
||||
import hashlib
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from rewrite_baked_lua_string import stage
|
||||
|
||||
|
||||
def fixture(root, contents):
|
||||
digest = hashlib.md5(contents).digest()
|
||||
(root / (digest.hex() + ".bytes")).write_bytes(contents)
|
||||
# One block and one module, with a 3-byte LuaV block trailer.
|
||||
index = (struct.pack(">IIII", 255, 4, 1, 1) + bytes(8) + digest +
|
||||
struct.pack(">III", 0, len(contents), 1) + bytes(8) +
|
||||
struct.pack(">II", len(contents), 0) + b"\x00\x00\x80")
|
||||
index_hash = hashlib.md5(index).digest()
|
||||
(root / ("LuaV_" + index_hash.hex() + ".bytes")).write_bytes(index)
|
||||
selector = bytearray(66)
|
||||
selector[:4] = b"SRMI"
|
||||
for i in range(4):
|
||||
selector[28 + 4 * i:32 + 4 * i] = index_hash[4 * i:4 * i + 4][::-1]
|
||||
struct.pack_into("<I", selector, 44, len(index))
|
||||
(root / "M_LuaV.bytes").write_bytes(selector)
|
||||
|
||||
|
||||
class RewriteTest(unittest.TestCase):
|
||||
def test_changed_string_updates_digest_and_round_trips(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
src, out = root / "src", root / "stage"
|
||||
src.mkdir()
|
||||
old, new = b"scene/a.prefab", b"scene/big.prefab"
|
||||
fixture(src, b"prefix" + bytes([len(old)]) + old + b"suffix")
|
||||
result = stage(src, out, old.decode(), new.decode())
|
||||
changed = (out / (result["new_block_md5"] + ".bytes")).read_bytes()
|
||||
self.assertIn(bytes([len(new)]) + new, changed)
|
||||
self.assertEqual(result["module_new_size"] - result["module_old_size"], len(new) - len(old))
|
||||
reverse = root / "reverse"
|
||||
result2 = stage(out, reverse, new.decode(), old.decode())
|
||||
self.assertEqual(result2["new_block_md5"], result["old_block_md5"])
|
||||
self.assertEqual(result2["new_index_md5"], result["old_index_md5"])
|
||||
self.assertEqual((reverse / "M_LuaV.bytes").read_bytes(), (src / "M_LuaV.bytes").read_bytes())
|
||||
|
||||
def test_duplicate_string_refuses_write(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
src, out = root / "src", root / "stage"
|
||||
src.mkdir()
|
||||
old = b"a.prefab"
|
||||
fixture(src, (bytes([len(old)]) + old) * 2)
|
||||
with self.assertRaisesRegex(ValueError, "ambiguous"):
|
||||
stage(src, out, old.decode(), "longer.prefab")
|
||||
self.assertFalse(out.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user