Add game clock commands, command completions and Cyrene login tooling
This commit is contained in:
1 parent
1fa9504176
commit
67a819a4d3
21 files changed
+1194
-7
No files matched your search
@@ -6,6 +6,14 @@
|
||||
|
||||
## CW — กำลังพัฒนา
|
||||
|
||||
คำสั่งเวลาใน GaC-Srtool: `/faketime status` ดูเวลาเกม, `/faketime <Unix seconds>` หรือ `/faketime <วันที่ RFC3339 พร้อม timezone>` ตั้งเวลาจำลอง และ `/faketime off` คืนเวลาจริง เวลาจำลองเดินต่อและบันทึกใน `game_clock.state_file` จาก config (override ด้วย `GAC_GAME_CLOCK_STATE_FILE`) ใช้กับ Login/heartbeat; ไม่เปลี่ยนนาฬิกา Windows, network timeout หรือเวลา log หลังเปลี่ยนให้เปิดสมุดใหม่ หรือเข้าเกมใหม่หากหน้าจอเก็บตารางเวลาไว้
|
||||
|
||||
ฉาก Login รถไฟ Cyrene ใช้งานได้แล้ว โดยใช้ `LoginShow_UP` เปิด manifest ชุดเต็มตั้งแต่เริ่มเกม และผูก event จบกับคลิปแอนิเมชัน UP ผู้ใช้ยืนยันทั้งภาพรถไฟสีขาว ฉากชมพูม่วง กลีบดอกไม้ และการ Logout แล้วกลับเข้าใหม่แล้ว เครื่องมือสร้างชุดสำเนาและคืนค่าต้นฉบับ รวมถึงผลตรวจในเกมอยู่ใน [บันทึกฉาก Login](docs/login-cyrene-investigation.md)
|
||||
|
||||
ตัวเลือก `lua_overlay` ปิดอยู่ตามปกติ หากเปิดทดลองต้องกำหนด `stage_dir`, `hotfix_dir` และ `streaming_dir` ใน `server.local.json` หรือผ่านตัวแปร `GAC_LUA_OVERLAY_*`; SDK จะตรวจไฟล์ดัชนีก่อนเริ่มและส่งไฟล์ Lua จากชุดที่กำหนดผ่าน URL ของ SDK ที่ตั้งไว้ ยังไม่ได้ยืนยันกับ Client จริง
|
||||
|
||||
ความคืบหน้าล่าสุด: เพิ่มการสร้างตัวเลือก Supply และห้อง Monster จากข้อมูลที่จับได้ พร้อมมอบรางวัล Encounter บางประเภทเมื่อผ่านเกณฑ์ โหนด Encounter หลัง Supply และ Orb หลังสู้ยังไม่ครบ และยังไม่ได้ยืนยันทางเดินใหม่นี้ในเกม จึงยังไม่ถือว่าเล่นจนจบได้ รายละเอียดอยู่ใน [CW route continuation audit](docs/cw-route-continuation-audit.md)
|
||||
|
||||
กำลังทำ Currency Wars ให้ครบวงจร รายละเอียดและขอบเขตที่ยังไม่เสร็จอยู่ใน [บันทึก CW](docs/currency-wars-investigation.md) ขณะนี้เพิ่ม catalog จาก Client, ข้อมูลเริ่มต้น `GridFightGetData` และรายการกิจกรรมแล้ว ยังไม่ถือว่า CW เล่นได้ครบ และระบบเซฟรอบ/ร้าน/เส้นทาง/การต่อสู้/ผลจบ CW ยังอยู่ระหว่างทำ
|
||||
|
||||
ทางเข้า CW ใช้ start anchor จาก MapEntrance และ RuntimeGroup ของ Client แทนการเลือกเสาวาร์ปแรกในแมพ ผู้ใช้ยืนยันว่าเข้าห้อง เห็นโฮโลแกรม และเปิด UI ค้างไว้ได้แล้ว ข้อมูลอยู่ใน `resources/CurrencyWarsCatalog.json` พร้อมที่มา ส่วนเริ่มรอบและเล่น CW ให้จบยังอยู่ระหว่างทำ
|
||||
@@ -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 แล้ว
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
# Endgame calendar and /faketime
|
||||
|
||||
The user confirmed PF/AS unlocked after relogging with the shared fake game
|
||||
clock. The Client's `ChallengeModule.GetChallengeGroupData(id)` exposes the
|
||||
actual `BeginTimeStamp` and `EndTimeStamp` used by its schedule checks.
|
||||
Captured windows showed that the last imported MoC and PF rounds ended on
|
||||
2026-10-26 at 20:00 UTC, while AS also contained a newer round starting then.
|
||||
Changing the activity-panel end time alone does not change these local
|
||||
challenge windows.
|
||||
|
||||
For this installation, the overlapping captured windows of groups 1037, 2028
|
||||
and 3022 run from Unix 1790452802 to 1793044800. The midpoint, Unix 1791748801
|
||||
(2026-10-11T20:00:01Z), was selected through `/faketime`, and the user verified
|
||||
the unlock. These are recorded Client observations, not IDs or dates embedded
|
||||
as defaults in the command implementation. The raw bounded capture remains
|
||||
in ignored `.runtime/endgame-windows-capture.json`.
|
||||
|
||||
Use the command field in GaC-Srtool:
|
||||
|
||||
- `/faketime status` (or `/faketime`) reports current game time and offset.
|
||||
- `/faketime 1791748801` sets Unix seconds.
|
||||
- `/faketime 2026-10-12T03:00:01+07:00` sets an explicit zoned date.
|
||||
- `/faketime off` restores real time (`reset` and `real` are equivalent).
|
||||
|
||||
The fake clock keeps advancing. Its offset is atomically saved to
|
||||
`game_clock.state_file`, configured in `server.json`, local overrides, or
|
||||
`GAC_GAME_CLOCK_STATE_FILE`. With no state file configured, reads use real time
|
||||
and attempts to set fake time fail with a configuration error. Login and
|
||||
heartbeat timestamps both use this clock. OS time, logs, leases and network
|
||||
timeouts remain based on real time. No avatar, inventory, EX or CW save is
|
||||
rewritten by this command. Reopen the guide after a heartbeat; relog if the
|
||||
Client retains schedule selections.
|
||||
|
||||
Validation: shared-state/real-time-reset/read-only-state/invalid-offset tests,
|
||||
runtime config and dispatch checks, launcher configuration check, both server
|
||||
builds, live SDK status and invalid-date requests, then a successful live
|
||||
`/faketime` update and user-confirmed Client unlock. Temporary capture scripts
|
||||
were removed from `scripts/` after collection; maintained probe sources remain
|
||||
under `tools/` only.
|
||||
@@ -0,0 +1,154 @@
|
||||
## Current verification
|
||||
|
||||
The full shipped ASB manifest and block table use the same schema/build header
|
||||
as their startup counterparts. The full block table references 9,270 installed
|
||||
block files; the original startup table references 15. The staging tool
|
||||
`tools/startup_manifest_overlay.py` validates selector hashes, format identity,
|
||||
and referenced block presence, then stages the full tables under startup names.
|
||||
Installation and exact restoration were verified in an isolated directory before
|
||||
installing on the closed Client, with original selectors retained separately.
|
||||
|
||||
The user then supplied an in-game screenshot confirming the requested white
|
||||
train, pink/purple nebula, petals and Click to Start UI. This proves the scene
|
||||
is correct and the startup manifest boundary was the loading failure.
|
||||
|
||||
The user also reported a hang after Logout and re-entering. The shipped Lua
|
||||
still registers its completion event against `Anim_LoginShow_LoadingWarpGO`,
|
||||
while the UP asset map identifies `Anim_LoginShow_LoadingWarpGO_UP`. A second
|
||||
staged rewrite changes only that event clip name, preserving the selected UP
|
||||
prefab. It was installed after the user closed the Client. The user confirmed
|
||||
that Logout and re-entering now succeeds. The requested scene and the reported
|
||||
relogin regression are therefore verified in the actual Client.
|
||||
|
||||
The installed solution uses the shipped startup Lua rewrite and full startup
|
||||
manifest projection; the experimental SDK overlay is disabled and is not
|
||||
required for this result. Paths and content hashes are taken from the supplied
|
||||
installation; no machine-specific installation path is embedded in the tools.
|
||||
Content-addressed original files and selector backups remain available. A Client
|
||||
update may replace these local startup selections; re-stage from that Client's
|
||||
own validated files instead of reusing another build's manifests.
|
||||
|
||||
# Cyrene login train investigation
|
||||
|
||||
The requested reference is the login screen with a light-colored train, pink
|
||||
petals and purple/pink nebula. An examination of the currently configured
|
||||
Client's startup asset blocks found ordinary login scene objects and assets
|
||||
such as `LoginShow`, `UI3D_LoginShow_StarCloud` and `Eff_Login_TrainFX`. This
|
||||
does not establish that the Cyrene variant is available: the asset reader did
|
||||
not completely decode the Client's asset blocks, and their manifest maps
|
||||
hashed block names rather than directly exposing all scene paths.
|
||||
|
||||
The server's maintained Lua scripts are delivered through the game session;
|
||||
this does not establish a way to change the scene before login. No Client file,
|
||||
login scene, server configuration or player data was modified for this request.
|
||||
The remaining work is to locate the variant and determine a supported startup
|
||||
selection or safe installation path before attempting a change. The supplied
|
||||
image is reference evidence only, not proof that this Client contains the scene.
|
||||
|
||||
A further user-provided screenshot shows the same scene on a 4.6.54 CN beta
|
||||
build. This confirms the scene can be used on that beta version. The user has
|
||||
indicated the asset exists; the remaining task is to find its asset and the
|
||||
selection mechanism. Startup config and the installed Video directory did not
|
||||
reveal a named Cyrene login video; those searches do not cover all assets.
|
||||
|
||||
## Variant located in the installed Client
|
||||
|
||||
A bounded offline scan of the installed Client's asset blocks found a block
|
||||
with SHA-256 `385d8abe0abf06a8255aa26dbb9d41e480ff1600e7126ebf0a3606dee2030487`.
|
||||
The asset map contains
|
||||
`assets/asbres/ui/ui3d/loginshow/loginshow_up.prefab`, an animator named
|
||||
`LoginShow_UP`, `LoginShow_Controller_UP`, and `MonoLoginTrainControl`.
|
||||
The same block includes objects named `Eff_Login_TrainChangeFX_01`,
|
||||
`Eff_Login_TrainFX_Flowers_02_Rate`, and
|
||||
`Eff_Login_TrainFX_Flowers_03_Rate`. This establishes that the Client has a
|
||||
separate flower/train-change login variant, as the user reported. It is a
|
||||
strong candidate for the reference screenshot but has not yet been rendered
|
||||
and visually verified. The map and extracted metadata are under ignored
|
||||
`.runtime/login-05d-map` and `.runtime/login-block-05d*`; no game file was
|
||||
modified. The native startup selection path still needs to be traced so the
|
||||
variant can be activated before login.
|
||||
|
||||
## Startup Lua path and staged installation
|
||||
|
||||
The installed Client's baked `GameStartupUI3DPage` stores the exact path
|
||||
`UI/UI3D/LoginShow/LoginShow.prefab`. The LuaV index maps that module to a
|
||||
byte range in a content-addressed Lua block. The maintained
|
||||
`tools/rewrite_baked_lua_string.py` builds a separate block and index changing
|
||||
only that length-prefixed path to `UI/UI3D/LoginShow/LoginShow_UP.prefab`;
|
||||
it validates every source block hash and updates module offsets and hashes.
|
||||
The matching SRMI selector is reconstructed with the new index digest.
|
||||
|
||||
The rewrite was reversed on an isolated verification directory and recovered
|
||||
the original Lua block, index and selector byte-for-byte. The staged files
|
||||
were installed by `tools/install_baked_lua_stage.py`; it retains original
|
||||
content-addressed files and saves the old selector outside the Client, writes
|
||||
new files before atomically replacing the selector, and supports restoration.
|
||||
The game was not terminated or restarted during installation. This proves file
|
||||
integrity and reversibility, not that the scene can load during startup: the
|
||||
UP prefab was found in the full asset manifest but not the startup manifest.
|
||||
Visual Client verification remains required.
|
||||
|
||||
The first Client attempt looped at the startup warning. The original selector
|
||||
was restored immediately, and the staged patch is not active. The latest
|
||||
available Client log reports a timeout while fetching `M_LuaV.bytes`; the log
|
||||
does not establish whether the modified selector caused the timeout. A further
|
||||
read-only check found a second, newer LuaV index under the Client's Persistent
|
||||
data. That active hotfix contains its own copy of `GameStartupUI3DPage`, so a
|
||||
rewrite of only the StreamingAssets index is insufficient. A rewrite of the
|
||||
hotfix copy has been staged separately but has not been installed. The update
|
||||
and selection process must be understood before another Client attempt.
|
||||
|
||||
## Controlled SDK overlay test
|
||||
|
||||
The optional overlay initially caused the SDK to panic at startup: its
|
||||
ServeMux subtree conflicted with the SDK's existing product-name wildcard
|
||||
login routes. Overlay dispatch now wraps the existing mux instead; an
|
||||
integration-style test exercises both the wildcard login and overlay paths.
|
||||
The SDK then started with an isolated, ignored test configuration, and an
|
||||
HTTP request returned the exact staged selector. All 14 blocks referenced by
|
||||
the staged hotfix index were found and their sizes and MD5 hashes verified.
|
||||
|
||||
The Client downloaded the staged selector and modified block into its
|
||||
Persistent Lua cache and was able to start. The user reported that the normal
|
||||
train still appeared, including after restarting the Client. The staged
|
||||
hotfix selector and downloaded block prove delivery to disk; they do not prove
|
||||
that this Lua module controlled the startup scene. A plausible remaining cause
|
||||
is that the login page is created from the shipped bootstrap bundle before the
|
||||
hotfix is applied; a separate activation or animation in the UP prefab is
|
||||
also possible. Neither explanation is established yet. The original Persistent
|
||||
selector was backed up before this test; the original StreamingAssets selector
|
||||
remained unchanged. No claim is made that the requested scene works.
|
||||
|
||||
The next controlled test installs the staged shipped Lua selector as well as
|
||||
the already-delivered hotfix selector. An initial installation attempt was
|
||||
cancelled because the Client was running. After the user confirmed it was
|
||||
closed, the installer completed and retained the original shipped selector in
|
||||
an ignored backup directory. Both selected modules now contain the UP prefab
|
||||
path. Client startup and visual verification of this combined test are pending;
|
||||
the original warning-loop attempt did not have the consistent SDK overlay.
|
||||
|
||||
## Confirmed startup failure boundary
|
||||
|
||||
The combined test produced a startup warning loop. Its Player.log explicitly
|
||||
names `UI/UI3D/LoginShow/LoginShow_UP.prefab` and reports
|
||||
`AsbManifestV5_Managed.GetBundleNameHashByBundleIndex name error: index=-1`.
|
||||
This proves that the patched shipped startup module executes, but the manifest
|
||||
available at that point cannot resolve the UP prefab's bundle. The UP prefab's
|
||||
presence in the full asset map is insufficient for early startup loading.
|
||||
The log is retained privately as `.runtime/cyrene-bootstrap-manifest-failure.log`.
|
||||
Both original Lua selectors were restored, and the experimental SDK was stopped
|
||||
and replaced by the original SDK so it cannot redeliver the failing patch.
|
||||
The next change must make the evidenced UP bundle and dependencies resolvable
|
||||
at startup, or defer selecting it until the full manifest is available. Repeating
|
||||
the path-only rewrite is not a fix. The requested pink scene remains unverified.
|
||||
|
||||
An opt-in Lua overlay has been added to the SDK source so an updater can be
|
||||
served a consistent staged selector, index and changed block while obtaining
|
||||
other files from local hotfix and shipped caches. It is disabled by default.
|
||||
`lua_overlay.enabled`, `stage_dir`, `hotfix_dir`, and `streaming_dir` belong in
|
||||
local configuration (or the corresponding `GAC_LUA_OVERLAY_*` environment
|
||||
variables). The SDK uses the shared public SDK base URL and a server-defined
|
||||
route, restricts requests to Lua manifest/block filenames, and checks the
|
||||
staged selector and index before startup. This is an unverified compatibility
|
||||
approach: the Client's startup request path, cache behavior, and UP prefab
|
||||
availability must still be confirmed. It has not been enabled or deployed.
|
||||
@@ -0,0 +1,104 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Only game-facing calendar time changes. Network timeouts, diagnostics, leases
|
||||
// and battle duration measurement continue to use the host's real clock.
|
||||
type GameClockState struct {
|
||||
OffsetMS int64 `json:"offset_ms"`
|
||||
}
|
||||
|
||||
func gameClockPath() (string, error) {
|
||||
cfg, err := LoadRuntimeConfig()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return cfg.GameClock.StateFile, nil
|
||||
}
|
||||
|
||||
func ReadGameClock() (GameClockState, error) {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return GameClockState{}, err
|
||||
}
|
||||
if path == "" {
|
||||
return GameClockState{}, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if os.IsNotExist(err) {
|
||||
return GameClockState{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return GameClockState{}, err
|
||||
}
|
||||
var state GameClockState
|
||||
if err := json.Unmarshal(raw, &state); err != nil {
|
||||
return state, fmt.Errorf("game clock state: %w", err)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func GameTime() (time.Time, error) {
|
||||
s, err := ReadGameClock()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
base := time.Now().UnixMilli()
|
||||
if s.OffsetMS < -base || s.OffsetMS > 253402300799999-base {
|
||||
return time.Time{}, fmt.Errorf("game clock outside supported calendar range")
|
||||
}
|
||||
ms := base + s.OffsetMS
|
||||
return time.UnixMilli(ms), nil
|
||||
}
|
||||
|
||||
func SetGameTime(target *time.Time) (time.Time, error) {
|
||||
path, err := gameClockPath()
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if path == "" {
|
||||
return time.Time{}, fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
|
||||
}
|
||||
now := time.Now()
|
||||
state := GameClockState{}
|
||||
if target != nil {
|
||||
if target.UnixMilli() < 0 || target.Year() > 9999 {
|
||||
return time.Time{}, fmt.Errorf("game time must be between Unix epoch and year 9999")
|
||||
}
|
||||
state.OffsetMS = target.UnixMilli() - now.UnixMilli()
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
raw, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
name := f.Name()
|
||||
defer os.Remove(name)
|
||||
if _, err = f.Write(raw); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = f.Sync(); err != nil {
|
||||
f.Close()
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = f.Close(); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
if err = os.Rename(name, path); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestGameClockSharedStateAndReset(t *testing.T) {
|
||||
raw, err := os.ReadFile(filepath.Join("..", "..", "server.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var cfg RuntimeConfig
|
||||
if err := json.Unmarshal(raw, &cfg); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dir := t.TempDir()
|
||||
cfg.GameClock.StateFile = filepath.Join(dir, "clock.json")
|
||||
configPath := filepath.Join(dir, "server.json")
|
||||
raw, _ = json.Marshal(cfg)
|
||||
if err := os.WriteFile(configPath, raw, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_CONFIG", configPath)
|
||||
t.Setenv("GAC_GAME_CLOCK_STATE_FILE", cfg.GameClock.StateFile)
|
||||
target := time.Date(2025, 1, 2, 3, 4, 5, 0, time.UTC)
|
||||
if _, err := SetGameTime(&target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err := GameTime()
|
||||
if err != nil || now.Sub(target) < 0 || now.Sub(target) > time.Second {
|
||||
t.Fatalf("target not preserved: %v %v", now, err)
|
||||
}
|
||||
before, _ := os.ReadFile(cfg.GameClock.StateFile)
|
||||
if _, err := ReadGameClock(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
after, _ := os.ReadFile(cfg.GameClock.StateFile)
|
||||
if string(before) != string(after) {
|
||||
t.Fatal("read rewrote state")
|
||||
}
|
||||
if _, err := SetGameTime(nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now, err = GameTime()
|
||||
if err != nil || time.Since(now).Abs() > time.Second {
|
||||
t.Fatalf("real time not restored: %v %v", now, err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.GameClock.StateFile, []byte(`{"offset_ms":9223372036854775807}`), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := GameTime(); err == nil {
|
||||
t.Fatal("overflowing offset accepted")
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@ package common
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
@@ -27,6 +30,21 @@ type RuntimeConfig struct {
|
||||
SDKPort uint16 `json:"sdk_port"`
|
||||
PublicGameHost string `json:"public_game_host"`
|
||||
PublicSDKURL string `json:"public_sdk_url"`
|
||||
LuaOverlay LuaOverlayConfig `json:"lua_overlay"`
|
||||
GameClock GameClockConfig `json:"game_clock"`
|
||||
}
|
||||
|
||||
type GameClockConfig struct {
|
||||
StateFile string `json:"state_file"`
|
||||
}
|
||||
|
||||
// Optional Client Lua update mirror. Machine-specific directories must be
|
||||
// supplied by the local config or environment; empty means disabled.
|
||||
type LuaOverlayConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
StageDir string `json:"stage_dir"`
|
||||
HotfixDir string `json:"hotfix_dir"`
|
||||
StreamingDir string `json:"streaming_dir"`
|
||||
}
|
||||
|
||||
// The schedule interval is an explicit operator policy shared by SDK, gateway,
|
||||
@@ -65,12 +83,31 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
|
||||
"GAC_ACCOUNT_NAME": &cfg.AccountName,
|
||||
"GAC_CLIENT_VERSION": &cfg.ClientVersion, "GAC_GAME_HOST": &cfg.GameHost,
|
||||
"GAC_SDK_HOST": &cfg.SDKHost, "GAC_PUBLIC_GAME_HOST": &cfg.PublicGameHost,
|
||||
"GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL,
|
||||
"GAC_PUBLIC_SDK_URL": &cfg.PublicSDKURL,
|
||||
"GAC_LUA_OVERLAY_STAGE_DIR": &cfg.LuaOverlay.StageDir,
|
||||
"GAC_LUA_OVERLAY_HOTFIX_DIR": &cfg.LuaOverlay.HotfixDir,
|
||||
"GAC_LUA_OVERLAY_STREAMING_DIR": &cfg.LuaOverlay.StreamingDir,
|
||||
"GAC_GAME_CLOCK_STATE_FILE": &cfg.GameClock.StateFile,
|
||||
} {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
*dest = value
|
||||
}
|
||||
}
|
||||
if value, ok := os.LookupEnv("GAC_LUA_OVERLAY_ENABLED"); ok {
|
||||
enabled, err := strconv.ParseBool(value)
|
||||
if err != nil {
|
||||
return cfg, fmt.Errorf("GAC_LUA_OVERLAY_ENABLED must be a boolean")
|
||||
}
|
||||
cfg.LuaOverlay.Enabled = enabled
|
||||
}
|
||||
if cfg.GameClock.StateFile != "" {
|
||||
if strings.TrimSpace(cfg.GameClock.StateFile) == "" {
|
||||
return cfg, fmt.Errorf("game_clock.state_file must be a file path")
|
||||
}
|
||||
if info, err := os.Stat(cfg.GameClock.StateFile); err == nil && info.IsDir() {
|
||||
return cfg, fmt.Errorf("game_clock.state_file must not be a directory")
|
||||
}
|
||||
}
|
||||
for name, dest := range map[string]*uint16{"GAC_GAME_PORT": &cfg.GamePort, "GAC_SDK_PORT": &cfg.SDKPort} {
|
||||
if value, ok := os.LookupEnv(name); ok {
|
||||
n, err := strconv.ParseUint(value, 10, 16)
|
||||
@@ -119,6 +156,31 @@ func LoadRuntimeConfig() (RuntimeConfig, error) {
|
||||
if cfg.PlayerUID == 0 {
|
||||
return cfg, fmt.Errorf("runtime config requires a nonzero player_uid")
|
||||
}
|
||||
if cfg.LuaOverlay.Enabled {
|
||||
for name, directory := range map[string]string{"stage_dir": cfg.LuaOverlay.StageDir, "hotfix_dir": cfg.LuaOverlay.HotfixDir, "streaming_dir": cfg.LuaOverlay.StreamingDir} {
|
||||
if strings.TrimSpace(directory) == "" {
|
||||
return cfg, fmt.Errorf("lua_overlay.%s is required when enabled", name)
|
||||
}
|
||||
info, err := os.Stat(directory)
|
||||
if err != nil || !info.IsDir() {
|
||||
return cfg, fmt.Errorf("lua_overlay.%s must be an existing directory", name)
|
||||
}
|
||||
}
|
||||
selector, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "M_LuaV.bytes"))
|
||||
if err != nil || len(selector) != 66 || string(selector[:4]) != "SRMI" {
|
||||
return cfg, fmt.Errorf("lua_overlay stage selector is invalid")
|
||||
}
|
||||
var indexDigest [16]byte
|
||||
for i := 0; i < 4; i++ {
|
||||
for j := 0; j < 4; j++ {
|
||||
indexDigest[i*4+j] = selector[28+i*4+3-j]
|
||||
}
|
||||
}
|
||||
index, err := os.ReadFile(filepath.Join(cfg.LuaOverlay.StageDir, "LuaV_"+hex.EncodeToString(indexDigest[:])+".bytes"))
|
||||
if err != nil || len(index) != int(binary.LittleEndian.Uint32(selector[44:48])) || md5.Sum(index) != indexDigest {
|
||||
return cfg, fmt.Errorf("lua_overlay staged index is missing or invalid")
|
||||
}
|
||||
}
|
||||
if cfg.GamePort == 0 || cfg.SDKPort == 0 || strings.TrimSpace(cfg.ClientVersion) == "" {
|
||||
return cfg, fmt.Errorf("runtime config requires client_version, game_port and sdk_port")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"crypto/md5"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
@@ -10,7 +13,7 @@ import (
|
||||
// deployment defaults. These tests must never bind sockets or start servers.
|
||||
func TestRuntimeConfigOverrides(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
for _, name := range []string{"GAC_CONFIG", "GAC_ACCOUNT_NAME", "GAC_PLAYER_UID", "GAC_ACTIVITY_BEGIN_TIME", "GAC_ACTIVITY_END_TIME", "GAC_CLIENT_VERSION", "GAC_GAME_HOST", "GAC_GAME_PORT", "GAC_SDK_HOST", "GAC_SDK_PORT", "GAC_PUBLIC_GAME_HOST", "GAC_PUBLIC_SDK_URL", "GAC_LIVE_BATTLE_STALE_SECONDS"} {
|
||||
for _, name := range []string{"GAC_CONFIG", "GAC_ACCOUNT_NAME", "GAC_PLAYER_UID", "GAC_ACTIVITY_BEGIN_TIME", "GAC_ACTIVITY_END_TIME", "GAC_CLIENT_VERSION", "GAC_GAME_HOST", "GAC_GAME_PORT", "GAC_SDK_HOST", "GAC_SDK_PORT", "GAC_PUBLIC_GAME_HOST", "GAC_PUBLIC_SDK_URL", "GAC_LIVE_BATTLE_STALE_SECONDS", "GAC_LUA_OVERLAY_ENABLED", "GAC_LUA_OVERLAY_STAGE_DIR", "GAC_LUA_OVERLAY_HOTFIX_DIR", "GAC_LUA_OVERLAY_STREAMING_DIR"} {
|
||||
t.Setenv(name, "")
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -78,3 +81,57 @@ func TestRuntimeConfigRequiresFile(t *testing.T) {
|
||||
t.Fatal("missing config silently accepted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLuaOverlayConfigRequiresExistingDirectoriesAndAllowsEnvironmentOverride(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
t.Setenv("GAC_CONFIG", "server.json")
|
||||
base := `{"account_name":"fixture","player_uid":1,"activity_schedule":{"begin_time":0,"end_time":100},"srtool":{"lease_file":"lease.json","lease_seconds":1},"battle_diagnostics":{"live_report_stale_seconds":1},"client_version":"fixture","game_host":"localhost","game_port":32101,"sdk_host":"localhost","sdk_port":32102,"public_game_host":"example.invalid","public_sdk_url":"https://example.invalid"}`
|
||||
if err := os.WriteFile("server.json", []byte(base), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_ENABLED", "true")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STAGE_DIR", "missing")
|
||||
t.Setenv("GAC_LUA_OVERLAY_HOTFIX_DIR", "missing")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STREAMING_DIR", "missing")
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("missing directories accepted")
|
||||
}
|
||||
for _, name := range []string{"stage", "hotfix", "streaming"} {
|
||||
if err := os.Mkdir(name, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
index := []byte("fixture index")
|
||||
digest := md5.Sum(index)
|
||||
selector := make([]byte, 66)
|
||||
copy(selector, "SRMI")
|
||||
for i := 0; i < 4; i++ {
|
||||
for j := 0; j < 4; j++ {
|
||||
selector[28+i*4+j] = digest[i*4+3-j]
|
||||
}
|
||||
}
|
||||
binary.LittleEndian.PutUint32(selector[44:48], uint32(len(index)))
|
||||
if err := os.WriteFile(filepath.Join("stage", "M_LuaV.bytes"), selector, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("stage", "LuaV_"+hex.EncodeToString(digest[:])+".bytes"), index, 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_STAGE_DIR", "stage")
|
||||
t.Setenv("GAC_LUA_OVERLAY_HOTFIX_DIR", "hotfix")
|
||||
t.Setenv("GAC_LUA_OVERLAY_STREAMING_DIR", "streaming")
|
||||
cfg, err := LoadRuntimeConfig()
|
||||
if err != nil || !cfg.LuaOverlay.Enabled || cfg.LuaOverlay.StageDir != "stage" {
|
||||
t.Fatalf("overlay override: %+v, %v", cfg.LuaOverlay, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join("stage", "LuaV_"+hex.EncodeToString(digest[:])+".bytes"), []byte("corrupt"), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("corrupt staged index accepted")
|
||||
}
|
||||
t.Setenv("GAC_LUA_OVERLAY_ENABLED", "invalid")
|
||||
if _, err := LoadRuntimeConfig(); err == nil {
|
||||
t.Fatal("invalid boolean accepted")
|
||||
}
|
||||
}
|
||||
@@ -1,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,
|
||||
|
||||
@@ -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...)
|
||||
|
||||
@@ -63,6 +63,7 @@ func registerAPIRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("/api/teleport", corsWrapper(apiTeleport))
|
||||
mux.HandleFunc("/api/gachabase/fetch", corsWrapper(apiGachabaseFetch))
|
||||
mux.HandleFunc("/api/command", corsWrapper(apiCommand))
|
||||
mux.HandleFunc("/api/command-completions", corsWrapper(apiCommandCompletions))
|
||||
mux.HandleFunc("/api/client/unstuck", corsWrapper(apiClientUnstuck))
|
||||
mux.HandleFunc("/api/client/unstuck/result", corsWrapper(apiClientUnstuckResult))
|
||||
mux.HandleFunc("/api/data-in-game", corsWrapper(apiDataInGame))
|
||||
@@ -803,6 +804,12 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
apiCurrencyWarsCommand(w, cmd)
|
||||
return
|
||||
}
|
||||
if parts := strings.Fields(cmd); len(parts) > 0 && strings.EqualFold(parts[0], "/faketime") {
|
||||
apiMu.Lock()
|
||||
defer apiMu.Unlock()
|
||||
apiFakeTimeCommand(w, cmd)
|
||||
return
|
||||
}
|
||||
|
||||
apiMu.Lock()
|
||||
defer apiMu.Unlock()
|
||||
@@ -896,7 +903,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
case "/help":
|
||||
output = "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Teleport to scene\n /avatar <id> [level] [rank] - Modify avatar\n /give <item_id> [count] - Add items to inventory\n /clear lineup - Clear active lineup slots"
|
||||
output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
|
||||
output += "Available GM Commands:\n /heal - Fully restore team HP, SP, and Energy\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Teleport to scene\n /avatar <id> [level] [rank] - Modify avatar\n /give <item_id> [count] - Add items to inventory\n /clear lineup - Clear active lineup slots"
|
||||
|
||||
default:
|
||||
output = fmt.Sprintf("[OK] Custom command '%s' dispatched to server.", cmd)
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"net/http"
|
||||
"sort"
|
||||
)
|
||||
|
||||
type commandCompletion struct {
|
||||
Value string `json:"value"`
|
||||
Label string `json:"label"`
|
||||
Plane uint32 `json:"plane,omitempty"`
|
||||
Star uint32 `json:"star,omitempty"`
|
||||
Avatar uint32 `json:"avatar,omitempty"`
|
||||
}
|
||||
|
||||
// Read-only, resource-backed argument choices. No save or live run is changed.
|
||||
func apiCommandCompletions(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
out := map[string][]commandCompletion{}
|
||||
add := func(kind string, id uint32, label string) {
|
||||
out[kind] = append(out[kind], commandCompletion{Value: fmt.Sprint(id), Label: label})
|
||||
}
|
||||
var issues []string
|
||||
if raw, err := common.ReadResourceFile("resources/InventoryItemConfig.json"); err == nil {
|
||||
var items struct {
|
||||
IDs []uint32 `json:"item_ids"`
|
||||
}
|
||||
if err = json.Unmarshal(raw, &items); err == nil {
|
||||
for _, id := range items.IDs {
|
||||
add("items", id, fmt.Sprintf("Item %d", id))
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
if res, err := common.GameRes(); err == nil {
|
||||
planes := map[uint32]bool{}
|
||||
floors := map[[2]uint32]bool{}
|
||||
for _, entry := range res.MapEntrances {
|
||||
if !planes[entry.PlaneID] {
|
||||
add("planes", entry.PlaneID, fmt.Sprintf("Plane %d", entry.PlaneID))
|
||||
planes[entry.PlaneID] = true
|
||||
}
|
||||
key := [2]uint32{entry.PlaneID, entry.FloorID}
|
||||
if !floors[key] {
|
||||
out["floors"] = append(out["floors"], commandCompletion{Value: fmt.Sprint(entry.FloorID), Label: fmt.Sprintf("Floor %d", entry.FloorID), Plane: entry.PlaneID})
|
||||
floors[key] = true
|
||||
}
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
if catalog, err := common.LoadCurrencyWarsCatalog(); err == nil {
|
||||
for _, v := range catalog.CapturedVariants {
|
||||
out["cw_roles"] = append(out["cw_roles"], commandCompletion{Value: fmt.Sprint(v.Config.ID), Label: fmt.Sprintf("Role %d · %d★", v.Config.ID, v.Star), Star: v.Star, Avatar: v.Config.AvatarID})
|
||||
}
|
||||
if equipment, e := common.LoadCurrencyWarsEquipment(catalog); e == nil {
|
||||
for id, row := range equipment {
|
||||
add("cw_equip", id, row.AbilityName)
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, e.Error())
|
||||
}
|
||||
if rewards, e := common.LoadCurrencyWarsRewards(catalog); e == nil {
|
||||
for id := range rewards.Orbs {
|
||||
add("cw_orb", id, fmt.Sprintf("Orb %d", id))
|
||||
}
|
||||
for id := range rewards.Consumables {
|
||||
add("cw_consumable", id, fmt.Sprintf("Consumable %d", id))
|
||||
}
|
||||
for id := range rewards.Forges {
|
||||
add("cw_forge", id, fmt.Sprintf("Forge %d", id))
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, e.Error())
|
||||
}
|
||||
} else {
|
||||
issues = append(issues, err.Error())
|
||||
}
|
||||
for key := range out {
|
||||
sort.Slice(out[key], func(i, j int) bool {
|
||||
if out[key][i].Value == out[key][j].Value {
|
||||
return out[key][i].Star < out[key][j].Star
|
||||
}
|
||||
return out[key][i].Value < out[key][j].Value
|
||||
})
|
||||
}
|
||||
writeJSON(w, map[string]any{"choices": out, "issues": issues})
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"gac-hkrpg-go/internal/common"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
|
||||
parts := strings.Fields(cmd)
|
||||
usage := "Usage: /faketime [status | off | <Unix seconds> | <RFC3339 date with timezone>]. Time keeps advancing. Reopen the guide after the next heartbeat; relogin if it caches schedules."
|
||||
var now time.Time
|
||||
var err error
|
||||
arg := strings.TrimSpace(strings.TrimPrefix(cmd, parts[0]))
|
||||
switch strings.ToLower(arg) {
|
||||
case "", "status":
|
||||
now, err = common.GameTime()
|
||||
case "off", "reset", "real":
|
||||
now, err = common.SetGameTime(nil)
|
||||
default:
|
||||
var target time.Time
|
||||
if seconds, e := strconv.ParseInt(arg, 10, 64); e == nil {
|
||||
if seconds < 0 || seconds > 253402300799 {
|
||||
err = fmt.Errorf("Unix seconds outside supported calendar range")
|
||||
} else {
|
||||
target = time.Unix(seconds, 0)
|
||||
}
|
||||
} else {
|
||||
target, err = time.Parse(time.RFC3339, arg)
|
||||
}
|
||||
if err == nil {
|
||||
now, err = common.SetGameTime(&target)
|
||||
}
|
||||
}
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"success": false, "output": err.Error() + "\n" + usage})
|
||||
return
|
||||
}
|
||||
state, err := common.ReadGameClock()
|
||||
if err != nil {
|
||||
writeJSON(w, map[string]any{"success": false, "output": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "output": fmt.Sprintf("Game time: %s (offset %d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, usage)})
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
)
|
||||
|
||||
// Route name is part of this server's HTTP interface, not a machine URL.
|
||||
const luaOverlayRoute = "/gacy-lua-overlay"
|
||||
|
||||
var luaBundleName = regexp.MustCompile(`^(?:M_LuaV|LuaV_[0-9a-f]{32}|[0-9a-f]{32})\.bytes$`)
|
||||
|
||||
// Dispatch outside ServeMux: a subtree registration conflicts with the
|
||||
// product-name wildcard used by the existing login endpoints in newer Go.
|
||||
func withLuaOverlay(next http.Handler, cfg common.LuaOverlayConfig) http.Handler {
|
||||
if !cfg.Enabled {
|
||||
return next
|
||||
}
|
||||
overlay := luaOverlayHandler(cfg)
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasPrefix(r.URL.Path, luaOverlayRoute+"/") {
|
||||
overlay(w, r)
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// Serve only the Lua updater's known flat namespace. Stage files win, then the
|
||||
// local hotfix and shipping files; no filesystem path or remote URL is derived
|
||||
// from untrusted input. The original caches remain untouched.
|
||||
func luaOverlayHandler(cfg common.LuaOverlayConfig) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet && r.Method != http.MethodHead {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
path := strings.TrimPrefix(r.URL.Path, luaOverlayRoute+"/")
|
||||
path = strings.TrimPrefix(path, "client/Windows/")
|
||||
if !luaBundleName.MatchString(path) {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
for _, root := range []string{cfg.StageDir, cfg.HotfixDir, cfg.StreamingDir} {
|
||||
file, err := os.Open(filepath.Join(root, path))
|
||||
if os.IsNotExist(err) {
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
http.Error(w, "Lua overlay read failed", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
info, err := file.Stat()
|
||||
if err != nil || !info.Mode().IsRegular() {
|
||||
file.Close()
|
||||
http.Error(w, "Lua overlay file invalid", http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/octet-stream")
|
||||
http.ServeContent(w, r, path, info.ModTime(), file)
|
||||
file.Close()
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package sdkserver
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gac-hkrpg-go/internal/common"
|
||||
pb "gac-hkrpg-go/protocol/proto"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
func TestLuaOverlayGatewayAndLookup(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
cfg := common.LuaOverlayConfig{Enabled: true, StageDir: filepath.Join(base, "stage"), HotfixDir: filepath.Join(base, "hotfix"), StreamingDir: filepath.Join(base, "streaming")}
|
||||
for _, dir := range []string{cfg.StageDir, cfg.HotfixDir, cfg.StreamingDir} {
|
||||
if err := os.Mkdir(dir, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, data := range []struct{ dir, name, content string }{{cfg.StageDir, "M_LuaV.bytes", "stage"}, {cfg.HotfixDir, "M_LuaV.bytes", "old"}, {cfg.StreamingDir, "0123456789abcdef0123456789abcdef.bytes", "base"}} {
|
||||
if err := os.WriteFile(filepath.Join(data.dir, data.name), []byte(data.content), 0600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
h := luaOverlayHandler(cfg)
|
||||
for _, tt := range []struct {
|
||||
path, want string
|
||||
code int
|
||||
}{{luaOverlayRoute + "/client/Windows/M_LuaV.bytes", "stage", 200}, {luaOverlayRoute + "/0123456789abcdef0123456789abcdef.bytes", "base", 200}, {luaOverlayRoute + "/client/Windows/../../secret", "404 page not found\n", 404}} {
|
||||
w := httptest.NewRecorder()
|
||||
h(w, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
if w.Code != tt.code || w.Body.String() != tt.want {
|
||||
t.Fatalf("%s: %d %q", tt.path, w.Code, w.Body.String())
|
||||
}
|
||||
}
|
||||
// The existing SDK has product-name wildcard routes; overlay dispatch must
|
||||
// coexist with them without a ServeMux registration panic.
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/{product_name}/mdk/shield/api/login", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusNoContent) })
|
||||
wrapped := withLuaOverlay(mux, cfg)
|
||||
for _, tt := range []struct {
|
||||
path string
|
||||
want int
|
||||
}{{"/game/mdk/shield/api/login", http.StatusNoContent}, {luaOverlayRoute + "/M_LuaV.bytes", http.StatusOK}} {
|
||||
w := httptest.NewRecorder()
|
||||
wrapped.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tt.path, nil))
|
||||
if w.Code != tt.want {
|
||||
t.Fatalf("%s: got %d want %d", tt.path, w.Code, tt.want)
|
||||
}
|
||||
}
|
||||
state := &appState{runtime: common.RuntimeConfig{PublicSDKURL: "https://sdk.example.invalid", PublicGameHost: "host.example.invalid", GamePort: 1, LuaOverlay: cfg}, hotfixMap: map[string]VersionConfig{"fixture": {LuaURL: "https://upstream.example.invalid"}}}
|
||||
w := httptest.NewRecorder()
|
||||
queryGateway(state)(w, httptest.NewRequest(http.MethodGet, "/query_gateway?version=fixture", nil))
|
||||
wire, err := base64.StdEncoding.DecodeString(w.Body.String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gateway pb.Gateserver
|
||||
if err := proto.Unmarshal(wire, &gateway); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if gateway.LuaUrl != state.runtime.PublicSDKURL+luaOverlayRoute {
|
||||
t.Fatalf("wrong Lua URL: %q", gateway.LuaUrl)
|
||||
}
|
||||
if state.hotfixMap["fixture"].LuaURL != "https://upstream.example.invalid" {
|
||||
t.Fatal("upstream config was mutated")
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,9 @@ func queryGateway(state *appState) http.HandlerFunc {
|
||||
_ = params{}
|
||||
|
||||
cfg := state.getOrInsertHotfix(version, seed)
|
||||
if state.runtime.LuaOverlay.Enabled {
|
||||
cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute
|
||||
}
|
||||
|
||||
rsp := &goproto.Gateserver{
|
||||
Ip: state.runtime.PublicGameHost,
|
||||
@@ -386,7 +389,7 @@ func Listen() error {
|
||||
addr := runtime.SDKAddress()
|
||||
Log("sdkserver is listening at " + addr)
|
||||
Log(fmt.Sprintf("REST API Gateway is live at %s/ (for GaC-Srtool)", runtime.PublicSDKURL))
|
||||
return http.ListenAndServe(addr, mux)
|
||||
return http.ListenAndServe(addr, withLuaOverlay(mux, runtime.LuaOverlay))
|
||||
}
|
||||
|
||||
func Log(msg string) { fmt.Println("[sdkserver]", msg) }
|
||||
@@ -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) {
|
||||
|
||||
@@ -12,6 +12,10 @@
|
||||
"sdk_port": 21000,
|
||||
"public_game_host": "127.0.0.1",
|
||||
"public_sdk_url": "http://127.0.0.1:21000",
|
||||
"game_clock": { "state_file": ".runtime/game-clock.json" },
|
||||
"lua_overlay": {
|
||||
"enabled": false
|
||||
},
|
||||
"battle_diagnostics": {
|
||||
"client_battle_type": "",
|
||||
"live_report_stale_seconds": 10
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install or restore a staged Lua selector without deleting source bundles."""
|
||||
import argparse
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def selector_digest(data):
|
||||
if len(data) != 66 or data[:4] != b"SRMI":
|
||||
raise ValueError("unsupported LuaV selector")
|
||||
return b"".join(data[i:i + 4][::-1] for i in range(28, 44, 4)).hex()
|
||||
|
||||
|
||||
def atomic_copy(source, target):
|
||||
fd, name = tempfile.mkstemp(prefix=".gacy-luav-", dir=target.parent)
|
||||
try:
|
||||
with os.fdopen(fd, "wb") as out, source.open("rb") as inp:
|
||||
shutil.copyfileobj(inp, out)
|
||||
out.flush()
|
||||
os.fsync(out.fileno())
|
||||
os.replace(name, target)
|
||||
finally:
|
||||
if os.path.exists(name):
|
||||
os.unlink(name)
|
||||
|
||||
|
||||
def install(source, stage, backup):
|
||||
source, stage, backup = source.resolve(strict=True), stage.resolve(strict=True), backup.resolve()
|
||||
if source == stage or source in stage.parents or backup == source or source in backup.parents:
|
||||
raise ValueError("stage and backup must be outside the installation")
|
||||
current = source / "M_LuaV.bytes"
|
||||
current_bytes = current.read_bytes()
|
||||
old_digest = selector_digest(current_bytes)
|
||||
if not (source / ("LuaV_" + old_digest + ".bytes")).is_file():
|
||||
raise ValueError("original selected index is unavailable")
|
||||
new_selector = stage / "M_LuaV.bytes"
|
||||
new_digest = selector_digest(new_selector.read_bytes())
|
||||
index = stage / ("LuaV_" + new_digest + ".bytes")
|
||||
if hashlib.md5(index.read_bytes()).hexdigest() != new_digest:
|
||||
raise ValueError("staged index digest differs")
|
||||
blocks = [p for p in stage.glob("*.bytes") if p.name != "M_LuaV.bytes" and not p.name.startswith("LuaV_")]
|
||||
if len(blocks) != 1 or hashlib.md5(blocks[0].read_bytes()).hexdigest() != blocks[0].stem:
|
||||
raise ValueError("staged block digest differs")
|
||||
if bytes.fromhex(blocks[0].stem) not in index.read_bytes():
|
||||
raise ValueError("staged index does not reference staged block")
|
||||
backup.mkdir(parents=True, exist_ok=True)
|
||||
backup_file = backup / ("M_LuaV-" + old_digest + ".bytes")
|
||||
if backup_file.exists() and backup_file.read_bytes() != current_bytes:
|
||||
raise ValueError("existing selector backup differs")
|
||||
if not backup_file.exists():
|
||||
atomic_copy(current, backup_file)
|
||||
for item in (blocks[0], index):
|
||||
dst = source / item.name
|
||||
if dst.exists():
|
||||
if hashlib.md5(dst.read_bytes()).hexdigest() != hashlib.md5(item.read_bytes()).hexdigest():
|
||||
raise ValueError("existing content-addressed file differs")
|
||||
else:
|
||||
atomic_copy(item, dst)
|
||||
if current.read_bytes() != current_bytes:
|
||||
raise ValueError("selector changed during installation")
|
||||
atomic_copy(new_selector, current)
|
||||
if current.read_bytes() != new_selector.read_bytes():
|
||||
raise ValueError("installed selector did not verify")
|
||||
print("Installed staged Lua selector; original backup:", backup_file)
|
||||
|
||||
|
||||
def main():
|
||||
p = argparse.ArgumentParser()
|
||||
p.add_argument("--restore", action="store_true", help="restore the backup selector")
|
||||
p.add_argument("source", type=Path)
|
||||
p.add_argument("stage", type=Path)
|
||||
p.add_argument("backup", type=Path)
|
||||
args = p.parse_args()
|
||||
if args.restore:
|
||||
source, stage, backup = args.source.resolve(strict=True), args.stage.resolve(strict=True), args.backup.resolve(strict=True)
|
||||
if source == stage or source in stage.parents or source == backup or source in backup.parents:
|
||||
raise ValueError("stage and backup must be outside the installation")
|
||||
target = source / "M_LuaV.bytes"
|
||||
if target.read_bytes() != (stage / "M_LuaV.bytes").read_bytes():
|
||||
raise ValueError("current selector no longer matches this staged patch")
|
||||
candidates = list(backup.glob("M_LuaV-*.bytes"))
|
||||
if len(candidates) != 1:
|
||||
raise ValueError("backup selector is missing or ambiguous")
|
||||
original_digest = selector_digest(candidates[0].read_bytes())
|
||||
if candidates[0].name != "M_LuaV-" + original_digest + ".bytes" or not (source / ("LuaV_" + original_digest + ".bytes")).is_file():
|
||||
raise ValueError("backup selector or original index does not match")
|
||||
atomic_copy(candidates[0], target)
|
||||
print("Restored original Lua selector")
|
||||
else:
|
||||
install(args.source, args.stage, args.backup)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,119 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a staged Lua bundle rewrite; never modify the source installation.
|
||||
|
||||
The caller supplies the Client's Lua/Windows directory, output directory and
|
||||
two exact asset paths. This handles the observed LuaV big-endian index and
|
||||
SRMI digest format only; it refuses ambiguous input instead of guessing.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def md5(data):
|
||||
return hashlib.md5(data).digest()
|
||||
|
||||
|
||||
def replace_once(buf, old, new):
|
||||
if len(old) >= 128 or len(new) >= 128:
|
||||
raise ValueError("only single-byte Lua string lengths are supported")
|
||||
original = bytes([len(old)]) + old
|
||||
replacement = bytes([len(new)]) + new
|
||||
if buf.count(original) != 1:
|
||||
raise ValueError("the exact length-prefixed string must occur once")
|
||||
index = buf.index(original)
|
||||
return buf[:index] + replacement + buf[index + len(original):], index
|
||||
|
||||
|
||||
def stage(source, output, old, new):
|
||||
source, output = source.resolve(strict=True), output.resolve()
|
||||
if output == source or source in output.parents:
|
||||
raise ValueError("output must be outside the source installation")
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ValueError("output must be empty")
|
||||
selector = (source / "M_LuaV.bytes").read_bytes()
|
||||
if len(selector) != 66 or selector[:4] != b"SRMI":
|
||||
raise ValueError("unsupported LuaV selector")
|
||||
digest = b"".join(selector[i:i + 4][::-1] for i in range(28, 44, 4))
|
||||
index_path = source / ("LuaV_" + digest.hex() + ".bytes")
|
||||
index = bytearray(index_path.read_bytes())
|
||||
if md5(index) != digest or struct.unpack_from("<I", selector, 44)[0] != len(index):
|
||||
raise ValueError("LuaV selector digest or size does not match")
|
||||
if len(index) < 16 or struct.unpack_from(">II", index, 0) != (255, 4):
|
||||
raise ValueError("unsupported LuaV index header")
|
||||
blocks, total_modules = struct.unpack_from(">II", index, 8)
|
||||
pos, entries_seen, matches = 16, 0, []
|
||||
old, new = old.encode("utf-8"), new.encode("utf-8")
|
||||
for _ in range(blocks):
|
||||
if pos + 36 > len(index):
|
||||
raise ValueError("truncated LuaV block header")
|
||||
block_digest = bytes(index[pos + 8:pos + 24])
|
||||
reserved, size, count = struct.unpack_from(">III", index, pos + 24)
|
||||
table = pos + 36
|
||||
end = table + count * 16
|
||||
if reserved != 0 or end > len(index):
|
||||
raise ValueError("unsupported LuaV block table")
|
||||
block_path = source / (block_digest.hex() + ".bytes")
|
||||
data = block_path.read_bytes()
|
||||
if len(data) != size or md5(data) != block_digest:
|
||||
raise ValueError("Lua block digest or size differs")
|
||||
entries_seen += count
|
||||
needle = bytes([len(old)]) + old
|
||||
if data.count(needle):
|
||||
if data.count(needle) != 1:
|
||||
raise ValueError("ambiguous path within Lua block")
|
||||
location = data.index(needle)
|
||||
entries = [struct.unpack_from(">QII", index, table + 16 * i) for i in range(count)]
|
||||
containing = [(i, off, length) for i, (_, length, off) in enumerate(entries)
|
||||
if off <= location and location + len(needle) <= off + length]
|
||||
if len(containing) != 1:
|
||||
raise ValueError("Lua module index does not uniquely cover the string")
|
||||
matches.append((pos, table, data, entries, containing[0], location))
|
||||
if bytes(index[end:end + 3]) != b"\x00\x00\x80":
|
||||
raise ValueError("unsupported LuaV block trailer")
|
||||
pos = end + 3
|
||||
if entries_seen != total_modules or pos != len(index) or len(matches) != 1:
|
||||
raise ValueError("LuaV index size/count or matching block differs")
|
||||
pos, table, data, entries, (module, module_offset, module_len), location = matches[0]
|
||||
rewritten, _ = replace_once(data, old, new)
|
||||
delta = len(rewritten) - len(data)
|
||||
for i, (_, length, offset) in enumerate(entries):
|
||||
if i == module:
|
||||
length += delta
|
||||
elif offset >= module_offset + module_len:
|
||||
offset += delta
|
||||
elif offset < module_offset + module_len and offset + length > module_offset:
|
||||
raise ValueError("overlapping Lua module entries")
|
||||
if length < 0 or offset < 0 or offset + length > len(rewritten):
|
||||
raise ValueError("rewritten Lua module bounds invalid")
|
||||
struct.pack_into(">II", index, table + 16 * i + 8, length, offset)
|
||||
index[pos + 8:pos + 24] = md5(rewritten)
|
||||
struct.pack_into(">I", index, pos + 28, len(rewritten))
|
||||
new_index_digest = md5(index)
|
||||
updated_selector = bytearray(selector)
|
||||
for i in range(4):
|
||||
updated_selector[28 + i * 4:32 + i * 4] = new_index_digest[i * 4:i * 4 + 4][::-1]
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
(output / (md5(rewritten).hex() + ".bytes")).write_bytes(rewritten)
|
||||
(output / ("LuaV_" + new_index_digest.hex() + ".bytes")).write_bytes(index)
|
||||
(output / "M_LuaV.bytes").write_bytes(updated_selector)
|
||||
return {"old_block_md5": md5(data).hex(), "new_block_md5": md5(rewritten).hex(),
|
||||
"old_index_md5": digest.hex(), "new_index_md5": new_index_digest.hex(),
|
||||
"module_offset": module_offset, "module_old_size": module_len,
|
||||
"module_new_size": module_len + delta, "string_offset": location}
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("source", type=Path)
|
||||
parser.add_argument("output", type=Path)
|
||||
parser.add_argument("old_path")
|
||||
parser.add_argument("new_path")
|
||||
args = parser.parse_args()
|
||||
print(json.dumps(stage(args.source, args.output, args.old_path, args.new_path), indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,109 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Stage full shipped ASB manifests for early startup; retain originals.
|
||||
|
||||
Observed SRMI selector, SRAM v5 manifest and SRBM v1 block-table formats.
|
||||
This changes startup availability only; it does not select a login scene.
|
||||
All installation paths are explicit arguments. Restore refuses unrelated edits.
|
||||
"""
|
||||
import argparse
|
||||
import hashlib
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
from install_baked_lua_stage import atomic_copy, selector_digest
|
||||
|
||||
|
||||
KINDS = ('AsbV', 'BlockV')
|
||||
|
||||
|
||||
def selected(root, name):
|
||||
selector = root / ('M_' + name + '.bytes')
|
||||
raw = selector.read_bytes()
|
||||
digest = selector_digest(raw)
|
||||
data = root / (name + '_' + digest + '.bytes')
|
||||
content = data.read_bytes()
|
||||
if hashlib.md5(content).hexdigest() != digest or struct.unpack_from('<I', raw, 44)[0] != len(content):
|
||||
raise ValueError('selector size or digest mismatch: ' + name)
|
||||
return selector, raw, data, content
|
||||
|
||||
|
||||
def stage(source, output):
|
||||
source, output = source.resolve(strict=True), output.resolve()
|
||||
if source == output or source in output.parents:
|
||||
raise ValueError('stage must be outside installation')
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ValueError('stage must be empty')
|
||||
prepared = []
|
||||
for kind in KINDS:
|
||||
_, old, _, before = selected(source, 'Start_' + kind)
|
||||
_, full, path, content = selected(source, kind)
|
||||
if old[:28] != full[:28] or before[:16] != content[:16]:
|
||||
raise ValueError('startup and full manifest schema/build differ')
|
||||
if kind == 'BlockV':
|
||||
if content[:8] != b'SRBM\x00\x01\x00\x01':
|
||||
raise ValueError('unsupported block table')
|
||||
offset, count, stride = struct.unpack_from('<III', content, 16)
|
||||
if offset != 28 or stride != 24 or len(content) != offset + count * stride:
|
||||
raise ValueError('unsupported block table bounds')
|
||||
for i in range(count):
|
||||
row = content[offset+i*stride:offset+(i+1)*stride]
|
||||
digest = b''.join(row[j:j+4][::-1] for j in range(0, 16, 4)).hex()
|
||||
if not (source / (digest + '.block')).is_file():
|
||||
raise ValueError('missing referenced block: ' + digest)
|
||||
prepared.append((kind, full, path, content))
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
for kind, selector, path, content in prepared:
|
||||
(output / ('Start_' + path.name)).write_bytes(content)
|
||||
(output / ('M_Start_' + kind + '.bytes')).write_bytes(selector)
|
||||
print('Staged full manifests; all referenced block files exist')
|
||||
|
||||
|
||||
def apply(source, staged, backup, restore=False):
|
||||
source, staged, backup = source.resolve(strict=True), staged.resolve(strict=True), backup.resolve()
|
||||
if any(p == source or source in p.parents for p in (staged, backup)):
|
||||
raise ValueError('stage and backup must be outside installation')
|
||||
entries = []
|
||||
for kind in KINDS:
|
||||
name = 'M_Start_' + kind + '.bytes'
|
||||
current = source / name
|
||||
_, expected, blob, _ = selected(staged, 'Start_' + kind)
|
||||
saved = backup / name
|
||||
if restore:
|
||||
if current.read_bytes() != expected:
|
||||
raise ValueError('current selector is not this overlay: ' + name)
|
||||
selected_backup = saved.read_bytes()
|
||||
original = source / ('Start_' + kind + '_' + selector_digest(selected_backup) + '.bytes')
|
||||
if not original.is_file():
|
||||
raise ValueError('original manifest missing')
|
||||
else:
|
||||
selected(source, 'Start_' + kind)
|
||||
if saved.exists() and saved.read_bytes() != current.read_bytes():
|
||||
raise ValueError('existing backup differs')
|
||||
entries.append((current, saved, staged / name, blob))
|
||||
backup.mkdir(parents=True, exist_ok=True)
|
||||
for current, saved, selector, blob in entries:
|
||||
if restore:
|
||||
atomic_copy(saved, current)
|
||||
else:
|
||||
if not saved.exists():
|
||||
atomic_copy(current, saved)
|
||||
atomic_copy(blob, source / blob.name)
|
||||
atomic_copy(selector, current)
|
||||
if current.read_bytes() != (saved if restore else selector).read_bytes():
|
||||
raise ValueError('selector write verification failed')
|
||||
print('Restored original startup manifests' if restore else 'Installed startup manifest overlay; originals retained')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('mode', choices=('stage', 'install', 'restore'))
|
||||
parser.add_argument('source', type=Path)
|
||||
parser.add_argument('staged', type=Path)
|
||||
parser.add_argument('--backup', type=Path)
|
||||
args = parser.parse_args()
|
||||
if args.mode == 'stage':
|
||||
stage(args.source, args.staged)
|
||||
else:
|
||||
if args.backup is None:
|
||||
parser.error('--backup is required for install/restore')
|
||||
apply(args.source, args.staged, args.backup, args.mode == 'restore')
|
||||
@@ -0,0 +1,58 @@
|
||||
import hashlib
|
||||
import struct
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from rewrite_baked_lua_string import stage
|
||||
|
||||
|
||||
def fixture(root, contents):
|
||||
digest = hashlib.md5(contents).digest()
|
||||
(root / (digest.hex() + ".bytes")).write_bytes(contents)
|
||||
# One block and one module, with a 3-byte LuaV block trailer.
|
||||
index = (struct.pack(">IIII", 255, 4, 1, 1) + bytes(8) + digest +
|
||||
struct.pack(">III", 0, len(contents), 1) + bytes(8) +
|
||||
struct.pack(">II", len(contents), 0) + b"\x00\x00\x80")
|
||||
index_hash = hashlib.md5(index).digest()
|
||||
(root / ("LuaV_" + index_hash.hex() + ".bytes")).write_bytes(index)
|
||||
selector = bytearray(66)
|
||||
selector[:4] = b"SRMI"
|
||||
for i in range(4):
|
||||
selector[28 + 4 * i:32 + 4 * i] = index_hash[4 * i:4 * i + 4][::-1]
|
||||
struct.pack_into("<I", selector, 44, len(index))
|
||||
(root / "M_LuaV.bytes").write_bytes(selector)
|
||||
|
||||
|
||||
class RewriteTest(unittest.TestCase):
|
||||
def test_changed_string_updates_digest_and_round_trips(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
src, out = root / "src", root / "stage"
|
||||
src.mkdir()
|
||||
old, new = b"scene/a.prefab", b"scene/big.prefab"
|
||||
fixture(src, b"prefix" + bytes([len(old)]) + old + b"suffix")
|
||||
result = stage(src, out, old.decode(), new.decode())
|
||||
changed = (out / (result["new_block_md5"] + ".bytes")).read_bytes()
|
||||
self.assertIn(bytes([len(new)]) + new, changed)
|
||||
self.assertEqual(result["module_new_size"] - result["module_old_size"], len(new) - len(old))
|
||||
reverse = root / "reverse"
|
||||
result2 = stage(out, reverse, new.decode(), old.decode())
|
||||
self.assertEqual(result2["new_block_md5"], result["old_block_md5"])
|
||||
self.assertEqual(result2["new_index_md5"], result["old_index_md5"])
|
||||
self.assertEqual((reverse / "M_LuaV.bytes").read_bytes(), (src / "M_LuaV.bytes").read_bytes())
|
||||
|
||||
def test_duplicate_string_refuses_write(self):
|
||||
with tempfile.TemporaryDirectory() as folder:
|
||||
root = Path(folder)
|
||||
src, out = root / "src", root / "stage"
|
||||
src.mkdir()
|
||||
old = b"a.prefab"
|
||||
fixture(src, (bytes([len(old)]) + old) * 2)
|
||||
with self.assertRaisesRegex(ValueError, "ambiguous"):
|
||||
stage(src, out, old.decode(), "longer.prefab")
|
||||
self.assertFalse(out.exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user