From 67a819a4d3a1f9674b7432eb3f782d5cc3f5a184 Mon Sep 17 00:00:00 2001 From: CAtlas0784 Date: Fri, 30 Oct 2026 15:02:09 +0700 Subject: [PATCH] Add game clock commands, command completions and Cyrene login tooling --- README.md | 10 ++ docs/endgame-faketime.md | 39 +++++ docs/login-cyrene-investigation.md | 154 ++++++++++++++++++ internal/common/game_clock.go | 104 ++++++++++++ internal/common/game_clock_test.go | 58 +++++++ internal/common/runtime_config.go | 64 +++++++- internal/common/runtime_config_test.go | 59 ++++++- .../gameserver/handlers_authentication.go | 8 +- internal/gameserver/handlers_player.go | 6 +- internal/sdkserver/api.go | 10 +- internal/sdkserver/api_command_completions.go | 97 +++++++++++ internal/sdkserver/api_game_clock.go | 48 ++++++ internal/sdkserver/lua_overlay.go | 71 ++++++++ internal/sdkserver/lua_overlay_test.go | 72 ++++++++ internal/sdkserver/server.go | 5 +- run.ps1 | 9 + server.json | 4 + tools/install_baked_lua_stage.py | 97 +++++++++++ tools/rewrite_baked_lua_string.py | 119 ++++++++++++++ tools/startup_manifest_overlay.py | 109 +++++++++++++ tools/test_rewrite_baked_lua_string.py | 58 +++++++ 21 files changed, 1194 insertions(+), 7 deletions(-) create mode 100644 docs/endgame-faketime.md create mode 100644 docs/login-cyrene-investigation.md create mode 100644 internal/common/game_clock.go create mode 100644 internal/common/game_clock_test.go create mode 100644 internal/sdkserver/api_command_completions.go create mode 100644 internal/sdkserver/api_game_clock.go create mode 100644 internal/sdkserver/lua_overlay.go create mode 100644 internal/sdkserver/lua_overlay_test.go create mode 100644 tools/install_baked_lua_stage.py create mode 100644 tools/rewrite_baked_lua_string.py create mode 100644 tools/startup_manifest_overlay.py create mode 100644 tools/test_rewrite_baked_lua_string.py diff --git a/README.md b/README.md index 57fff87..3e9f67e 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,14 @@ ## CW — กำลังพัฒนา +คำสั่งเวลาใน GaC-Srtool: `/faketime status` ดูเวลาเกม, `/faketime ` หรือ `/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 ให้จบยังอยู่ระหว่างทำ @@ -118,6 +126,8 @@ Ping colors อยู่ใน `scripts/ping_rainbow.lua` เปลี่ยน 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 แล้ว diff --git a/docs/endgame-faketime.md b/docs/endgame-faketime.md new file mode 100644 index 0000000..ea201da --- /dev/null +++ b/docs/endgame-faketime.md @@ -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. diff --git a/docs/login-cyrene-investigation.md b/docs/login-cyrene-investigation.md new file mode 100644 index 0000000..dc2ee68 --- /dev/null +++ b/docs/login-cyrene-investigation.md @@ -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. diff --git a/internal/common/game_clock.go b/internal/common/game_clock.go new file mode 100644 index 0000000..c7ad0b3 --- /dev/null +++ b/internal/common/game_clock.go @@ -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 +} diff --git a/internal/common/game_clock_test.go b/internal/common/game_clock_test.go new file mode 100644 index 0000000..af391c6 --- /dev/null +++ b/internal/common/game_clock_test.go @@ -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") + } +} diff --git a/internal/common/runtime_config.go b/internal/common/runtime_config.go index 66a567b..77393a2 100644 --- a/internal/common/runtime_config.go +++ b/internal/common/runtime_config.go @@ -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") } diff --git a/internal/common/runtime_config_test.go b/internal/common/runtime_config_test.go index 4dccae8..618f94c 100644 --- a/internal/common/runtime_config_test.go +++ b/internal/common/runtime_config_test.go @@ -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") + } +} diff --git a/internal/gameserver/handlers_authentication.go b/internal/gameserver/handlers_authentication.go index a0c347f..bec6ed8 100644 --- a/internal/gameserver/handlers_authentication.go +++ b/internal/gameserver/handlers_authentication.go @@ -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, diff --git a/internal/gameserver/handlers_player.go b/internal/gameserver/handlers_player.go index 218b035..f975b00 100644 --- a/internal/gameserver/handlers_player.go +++ b/internal/gameserver/handlers_player.go @@ -42,7 +42,11 @@ 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...) diff --git a/internal/sdkserver/api.go b/internal/sdkserver/api.go index 58ef5c8..a7f07fd 100644 --- a/internal/sdkserver/api.go +++ b/internal/sdkserver/api.go @@ -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 [floor_id] - Teleport to scene\n /avatar [level] [rank] - Modify avatar\n /give [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 [floor_id] - Teleport to scene\n /avatar [level] [rank] - Modify avatar\n /give [count] - Add items to inventory\n /clear lineup - Clear active lineup slots" default: output = fmt.Sprintf("[OK] Custom command '%s' dispatched to server.", cmd) diff --git a/internal/sdkserver/api_command_completions.go b/internal/sdkserver/api_command_completions.go new file mode 100644 index 0000000..044889c --- /dev/null +++ b/internal/sdkserver/api_command_completions.go @@ -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}) +} diff --git a/internal/sdkserver/api_game_clock.go b/internal/sdkserver/api_game_clock.go new file mode 100644 index 0000000..f6c078a --- /dev/null +++ b/internal/sdkserver/api_game_clock.go @@ -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 | | ]. 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)}) +} diff --git a/internal/sdkserver/lua_overlay.go b/internal/sdkserver/lua_overlay.go new file mode 100644 index 0000000..ccce602 --- /dev/null +++ b/internal/sdkserver/lua_overlay.go @@ -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) + } +} diff --git a/internal/sdkserver/lua_overlay_test.go b/internal/sdkserver/lua_overlay_test.go new file mode 100644 index 0000000..e99b284 --- /dev/null +++ b/internal/sdkserver/lua_overlay_test.go @@ -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") + } +} diff --git a/internal/sdkserver/server.go b/internal/sdkserver/server.go index 819c459..6abeaef 100644 --- a/internal/sdkserver/server.go +++ b/internal/sdkserver/server.go @@ -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) } diff --git a/run.ps1 b/run.ps1 index d64e0eb..9fcaf92 100644 --- a/run.ps1 +++ b/run.ps1 @@ -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) { diff --git a/server.json b/server.json index c4b1774..41beb65 100644 --- a/server.json +++ b/server.json @@ -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 diff --git a/tools/install_baked_lua_stage.py b/tools/install_baked_lua_stage.py new file mode 100644 index 0000000..90104de --- /dev/null +++ b/tools/install_baked_lua_stage.py @@ -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() diff --git a/tools/rewrite_baked_lua_string.py b/tools/rewrite_baked_lua_string.py new file mode 100644 index 0000000..44d940e --- /dev/null +++ b/tools/rewrite_baked_lua_string.py @@ -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("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() diff --git a/tools/startup_manifest_overlay.py b/tools/startup_manifest_overlay.py new file mode 100644 index 0000000..9af0457 --- /dev/null +++ b/tools/startup_manifest_overlay.py @@ -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('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("