Add game clock commands, command completions and Cyrene login tooling

This commit is contained in:
CAtlas0784 committed 2026-10-30 15:02:09 +07:00
1 parent 1fa9504176
commit 67a819a4d3
21 files changed
+1194 -7

No files matched your search

+10
View File
@@ -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 ให้จบยังอยู่ระหว่างทำ
@@ -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 แล้ว
+39
View File
@@ -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.
+154
View File
@@ -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.
+104
View File
@@ -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
}
+58
View File
@@ -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")
}
}
+63 -1
View File
@@ -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")
}
+58 -1
View File
@@ -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,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,
+5 -1
View File
@@ -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...)
+9 -1
View File
@@ -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})
}
+48
View File
@@ -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)})
}
+71
View File
@@ -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)
}
}
+72
View File
@@ -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")
}
}
+4 -1
View File
@@ -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) }
+9
View File
@@ -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) {
+4
View File
@@ -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
+97
View File
@@ -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()
+119
View File
@@ -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()
+109
View File
@@ -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')
+58
View File
@@ -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()