Compare commits

1 Commits
Author SHA1 Message Date
CAtlas0784 658298ee86 fix: improve endgame clock and gameplay stability 2026-11-04 15:59:48 +07:00
46 changed files with 1915 additions and 99 deletions

No files matched your search

+7 -2
View File
@@ -30,7 +30,7 @@ Gateway รองรับการค้นหา metadata ตามเวอ
เริ่มพอร์ต CW จาก [Currency-War-SR ของ xaodev](https://gaytea.cod-algieba.ts.net/xaodev/Currency-War-SR) แล้ว: เพิ่ม factory ของ Encounter หลัง Supply, ตารางตัวเลือก/ความยากที่ revision ตรงกัน และ EXP ตามโหมด/บท/Boss โดยยังรักษาเส้นทาง Battle ที่จับไว้เดิม งานนี้ยังเป็นการพอร์ตบางส่วนและยังไม่ได้ยืนยันเส้นทางใหม่ใน Client อ่านสถานะ ผลทดสอบ และงานส่งต่อใน [CW xaodev handoff](docs/CW-XAODEV-CLAUDE-HANDOFF.md)
คำสั่งเวลาใน GaCy-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 หลังเปลี่ยนให้เปิดสมุดใหม่ หรือเข้าเกมใหม่หากหน้าจอเก็บตารางเวลาไว้
คำสั่งเวลาใน GaCy-Srtool: `/faketime status` ดูเวลาเกม, `/faketime <Unix seconds>` หรือ `/faketime <วันที่ RFC3339 พร้อม timezone>` ตั้งเวลาจำลอง และ `/faketime off` คืนเวลาจริง สำหรับ Endgame ใช้ `/faketime window <เวลาเริ่ม Unix> <เวลาจบ Unix>` เพื่อวนเวลาภายในช่วงที่ตรวจจาก Client แล้ว โดยเริ่มที่กลางช่วง เวลาจะย้อนกลับเมื่อถึงปลายช่วงและส่งผลต่อกิจกรรมอื่นที่ใช้เวลาเกมด้วย ต้องตรวจช่วงใหม่เมื่อเปลี่ยน Client เวลาบันทึกใน `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)
@@ -461,6 +461,7 @@ Client ยืนยันแล้วว่าเริ่มรอบ เปิ
พิมพ์ใน GM Console ของ GaCy srtool เมื่อเชื่อมต่อเซิร์ฟเวอร์:
- `/cw status` อ่านรอบ เงิน HP และเลเวลที่เซฟอยู่
- `/cw end` ส่งคำขอจบรอบ CW ปัจจุบันแบบ **Quit** รวมถึงตอนค้างหน้าเลือก คำสั่งรอ heartbeat และจะไม่แทรกกลาง Battle; ให้เกมเชื่อมต่อไว้จนกว่าจะจบ Battle และตรวจ `/cw status` หรือ log `CW_GM applied end` ข้อความ queued หมายถึงรับเข้าคิวแล้วเท่านั้น การจบรอบใช้ settlement แบบ Quit เดิม ไม่ถือว่าชนะ
- `/cw gold <จำนวนรวม>` ตั้งเงิน CW เช่น `/cw gold 1000`
- `/cw hp <จำนวนรวม>` ตั้ง HP ภายในค่าสูงสุดของรอบ
- `/cw exp <gain>` เพิ่ม EXP และคำนวณเลเวล/ความจุ/น้ำหนักร้านค้าตามกฎที่ดึงมา
@@ -470,6 +471,8 @@ Client ยืนยันแล้วว่าเริ่มรอบ เปิ
- `/cw equip <ItemID> [count]` เพิ่มอุปกรณ์ลงในกระเป๋า CW
- `/cw help` แสดงรูปแบบคำสั่งทั้งหมด
การสุ่มห้องยังไม่ครบ: การเริ่มรอบและผลเลือกประตูยังอาศัย capture ของโหมด/Division ที่มีอยู่ จึงเห็นเส้นทางเดิมซ้ำได้ ห้องที่สร้างภายหลังมีการสุ่มศัตรูบางส่วน แต่ยังไม่ได้ยืนยันกฎสุ่มชนิดห้องและ PetPal กับ Client การใช้ `/cw end` ไม่ได้ทำให้รอบถัดไปสุ่มห้องครบ
การมอบของและการตอบรับคิวคำสั่งจะคอมมิตพร้อมกับการทำ snapshot CAS และการแทนที่ไฟล์แบบ atomic เพื่อป้องกันการมอบของซ้ำซ้อนหลังหลุดการเชื่อมต่อ หากคำสั่งถูกปฏิเสธจะบันทึก log `CW_GM rejected` โดยไม่บันทึกการมอบของบางส่วน รายการเหล่านี้ครอบคลุมเฉพาะข้อมูลที่ดึงมา ไม่ใช่ไอเทมทุกชิ้นใน Client
- `/cw list orb|consumable|forge` แสดงรหัสรางวัลที่ดึงมา
@@ -489,7 +492,7 @@ Client ยืนยันแล้วว่าเริ่มรอบ เปิ
ไฟล์ `resources/ActivityPreviewPolicy.json` ใช้เลือกพรีแฟบหน้าต่างอีเวนต์จาก `PreviewGameplayCatalog.json` สำหรับพรีวิวอื่น ๆ ขณะนี้ถูกระงับไว้ก่อน เนื่องจากบันทึกจาก Client พบปัญหา asset รางวัลสูญหายและการค้นหาไอเทมผิดพลาดใน ActiveActivity จึงควรเปิดใช้งานแต่ละหน้าต่างใหม่เมื่อตรวจสอบ asset ที่ติดตั้ง, identity ของโมดูล และความเกี่ยวโยงของรางวัลเรียบร้อยแล้วเท่านั้น ส่วนการต่อสู้เฉพาะอีเวนต์และการกดรับรางวัลยังทำไม่เสร็จสมบูรณ์
หน้า Warp ส่งเฉพาะตู้ที่มีข้อมูลจาก Client และตรวจพบ prefab ใน `resources/GachaSandboxPolicy.json` เท่านั้น บันทึก Client พบว่า ID 2120 จากชุด handoff ไม่มีใน `GachaBasicInfo` ของ Client นี้ และ prefab ตู้จำกัดที่ตรวจสอบก็ไม่พบ การส่งตู้เหล่านั้นทำให้เปิดหน้าไม่ได้ จึงยังไม่สามารถปลดตู้ย้อนหลังจากชุด handoff ได้ การสุ่มถูกระงับไว้จนกว่าจะตรวจสอบ pool, การให้ไอเทม และ protocol กับ Client ได้ คำสั่ง `/unlockall warp` และ `/reload banner` ยังไม่ทำให้ตู้ที่ไม่ได้ตรวจสอบปรากฏใน Client
หน้า Warp ใช้ตู้จำลองจากรายชื่อตัวละครที่นำเข้าและภาพจาก Client โดย adapter สร้างแท็บจากตู้มาตรฐานที่ตรวจพบ asset แล้ว การสุ่มตู้ตัวละครใช้กติกา sandbox ใน `resources/GachaSandboxPolicy.json` และให้วัตถุดิบตัวละครซ้ำตาม resource ของเกม ไม่ใช่ตู้หรือเรททางการ ตู้มาตรฐานและมือใหม่สุ่มจากตัวละครที่มีในรายชื่อที่นำเข้า ตู้จาก handoff ที่ไม่มีในตาราง Client จะไม่ถูกส่งโดยตรง เพราะทำให้ Client เปิดหน้าไม่ได้ การแสดงหลายแท็บ การสุ่ม และการออกจากหน้าใน Client ยังต้องตรวจยืนยันอีกครั้ง
ข้อมูลตารางที่ดึงมาระหว่างรันและข้อจำกัดการตรวจสอบในปัจจุบันถูกบันทึกไว้ใน [docs/offline-gameplay-continuation.md](docs/offline-gameplay-continuation.md) การคอมไพล์ handler เหล่านี้ผ่านไม่ได้ยืนยันว่าหน้าจอหรือระบบการเล่นใน Client จะทำงานได้สมบูรณ์
@@ -586,3 +589,5 @@ CW ล่าสุด: ผู้ใช้ยืนยัน Battle ห้อง
3. **ข้อจำกัดการใช้งานและการค้า (Non-Commercial / Educational Use):**
- โปรเจกต์นี้จัดทำขึ้นเพื่อการวิจัย, การศึกษาโครงสร้างโปรโตคอลเครือข่าย และการใช้งานส่วนบุคคลเท่านั้น **ห้ามนำไปจำหน่าย แสวงหาผลกำไร หรือใช้ในเชิงพาณิชย์โดยไม่ได้รับอนุญาต**
Relic catalog: `resources/RelicClientCapture.json` records the four planar relic rows for sets 329 and 330 and their matching main/sub-affix groups read from the connected Client. `resources/InventoryItemConfig.json` includes the four templates; restart the game server to reload its cached catalog. A successful test or catalog reload does not establish that the Client has displayed or equipped the items.
+4 -1
View File
@@ -33,6 +33,9 @@ The project is currently under active development, targeting Client **4.6.53 OS*
#### CW Preparation Commands
- `/cw status`: Display saved round, gold, HP, and player level.
- `/cw end`: Queue a quit settlement for the active CW run, including when a choice is stuck. It applies on heartbeat after any active battle; `queued` does not mean the run has ended. Keep the game connected and check `/cw status` or the `CW_GM applied end` log.
CW room generation remains incomplete: the initial run and portal outcomes use captured presets, so the route may repeat. Some later enemies are generated, but the complete dynamic room/PetPal selection rules remain unverified. `/cw end` does not randomize a new run.
- `/cw gold <amount>`: Set CW currency (e.g. `/cw gold 1000`).
- `/cw hp <amount>`: Set current HP within maximum stage allowance.
- `/cw exp <gain>`: Add EXP and apply captured level/capacity/shop-weight rules.
@@ -60,7 +63,7 @@ The selected buff applies at the start of the next battle containing an actual C
`resources/ActivityPreviewPolicy.json` selects event-panel prefabs from `PreviewGameplayCatalog.json`. Additional previews are currently withheld: Client logs showed missing reward assets and invalid item lookups in ActiveActivity. Re-enable individual panels only after validating their installed assets, module identity and reward dependencies. Event-specific battles and reward claims remain incomplete.
Warp advertises only pools captured from this Client whose prefabs were verified in `resources/GachaSandboxPolicy.json`. The Client log reports that handoff ID 2120 is absent from its `GachaBasicInfo` table; the limited pool prefabs checked are also missing. Sending those IDs prevents the page from opening. Imported historical banners are therefore not unlocked, and draws are disabled until pool data, item grants and protocol fields are verified against this Client. The `/unlockall warp` and `/reload banner` commands do not make unverified banners visible.
Warp uses sandbox character banners based on the imported roster and Client art, with a presentation adapter built from the verified standard panel. Character draws use the sandbox rules in `resources/GachaSandboxPolicy.json` and grant configured duplicate materials; these are not official pools or rates. Standard and novice draws use the imported roster. Handoff IDs missing from the Client table are not sent directly because they break page entry. Multi-tab presentation, draws and exiting the page still require Client verification.
Runtime table captures and current verification limits are recorded in [docs/offline-gameplay-continuation.md](offline-gameplay-continuation.md). Compiling these handlers does not confirm the corresponding Client screens or gameplay.
+1 -1
View File
@@ -62,7 +62,7 @@ GaCy-Srtool 的持久化配置提供独立的 Aha's Help 辅助选择器。等
`resources/ActivityPreviewPolicy.json` 负责从 `PreviewGameplayCatalog.json` 选择活动面板预制体。其余面板因客户端日志检测到奖励资产缺失或无效物品查询而被暂时封锁。待逐一验证已安装资产及依赖后方可恢复开启。
Warp 仅发送已从当前客户端捕获且确认预制体存在的卡池。客户端日志显示交接包的 2120 不在本机表中,已检查的限定卡池资源也缺失;抽卡暂时禁用,等待核实卡池、奖励与协议。
Warp 使用已导入角色和客户端图片构造模拟卡池,抽卡使用 `resources/GachaSandboxPolicy.json` 中的沙盒规则,并按资源发放重复角色材料。交接包中客户端表缺少的 ID 不会直接发送;多标签、抽卡与退出页面仍需在客户端验证。
运行时捕获的数据表及当前验证边界均记录在 [docs/offline-gameplay-continuation.md](offline-gameplay-continuation.md)。
+11
View File
@@ -1,5 +1,16 @@
# Battle investigation — 2026-10-16
## Aha target when entering battle
The scene's `SceneCastSkillCsReq.CasterId` identifies the attacking actor;
scene actor entity IDs are lineup slot + 1. The technique path also recognizes
the avatar entity namespace. When that actor is present in the battle lineup,
the server now sends its battle slot in the Aha Global and Aha's Help buffs'
`TargetIndexList`. The configured passive owner and Aha Help owner are
unchanged. Requests without a resolved player actor, including monster ambushes
and direct stage entry, retain the previous target. This protocol-target mapping
still requires an in-Client check of the resulting Aha effect.
## หลักฐานที่ใช้อ้างอิง
- Upstream: [HonkaiSlopRail/pearl-sr](https://git.xeondev.com/HonkaiSlopRail/pearl-sr)
+22
View File
@@ -1,5 +1,27 @@
# Currency Wars route continuation audit
## 2026-11-01: stuck initial round and `/cw end`
The live server log rejected the initial RoundBegin at queue 2 with
`CW starter pool reservation is not supported`. A read-only copy of the live
snapshot showed a starter above its captured default star. On a detached copy,
the same run advanced through `cwInitialRound` after changing initial pool
accounting to use the existing captured `ReserveOwned`/merge rules, as later
shop restocks already do. No live snapshot was modified by this check, and the
new battle/Client path has not yet been verified.
`/cw end` queues a run- and UID-bound ordinary quit settlement, including
while a pending choice is present. An active Battle defers it until afterward.
The quit record, archive, removal of the run and acknowledgement of the command
share the persistent CAS transaction. It reports queued rather than claiming
success before heartbeat. It does not declare a win or randomize a new run.
The reported repeated rooms are consistent with `CurrencyWarsInitialRuns.json`
and `CurrencyWarsEntryTransitions.json`, which replay captured initial states
and portal outcomes. Later factory paths can generate some enemies, but the
dynamic room and PetPal replacement contract is still incomplete. No random
node IDs or fabricated distributions were added; see the historical notes below.
**2026-10-31 update:** An initial xaodev-based generated EliteBranch factory
has now been connected to the route fallback. The older unsupported-factory
statements below describe the preceding implementation. The new code passes
@@ -0,0 +1,44 @@
# CW start, promotion and 6900 investigation
## Findings (2026-11-02)
- `CurrencyWarsInitialRuns.json` contains one starting capture for each mode,
both for division 20101. `buildCurrencyWarsInitialRun` copies that capture;
the chosen portal then replaces the run with its recorded outcome from
`CurrencyWarsEntryTransitions.json`. Its offered portals, starter roles,
route displays and saved boss/camp choices are consequently repeated. The
later enemy factory uses those saved boss/camp choices. This is independent
of whether the player has been promoted; it is not a procedural start.
- Terminal victory calls `currencyWarsTerminalSettlement`, which calculates
and saves a division increase. Quit and `/cw end` are not wins and do not
promote. The present ranking calculation is implementation policy, not a
verified original-server formula. A promoted division has no matching
initial capture, so a subsequent start for that division is rejected; no
successful full-run Client verification is established here.
- Retcode 6900 is `RET_GRID_FIGHT_CONF_MISS` and covers multiple errors. In
the available root `gameserver.log`, the attempted pending action at queue 1
with request arm `DCPHLHNLNKF` was rejected as unsupported, followed by a
successful portal selection (`MFFCPLBNHCF`). The empty request payload does
not by itself prove which button was pressed. It must not be assumed that
every reported 6900 has this cause. The isolated `for ship` log instead
records a later initial-round rejection on a two-star starter; that defect
was fixed in `cwInitialRound`, but the old log cannot prove a newer binary
was deployed or that the Client accepted it.
- The revision-matching xaodev reference leaves portal reroll counts zero and
explicitly does not offer rerolls. Its random portal generation depends on
general effect handling, while this server only has recorded outcomes for
the captured choices. Adding arbitrary portal IDs, returning success without
applying their effects, or swapping in a different captured battle would
conceal the missing behavior and risk the previously verified Battle route.
## Work still needed
Implement and validate a resource-backed run generator and portal effect
transactions for each supported choice, with a coherent route, camp, boss,
starter team, pending actions and battle payload. Cover promoted divisions
without changing existing confirmed Battle wiring. Resolve the exact meaning
of request arm `DCPHLHNLNKF` with revision-matching static or recorded evidence
before implementing it. For a new 6900, identify the active executable and
the corresponding `CW_PENDING_ACTION` rejection rather than relying on the
numeric return code alone. Offline tests are necessary but do not establish
Client gameplay acceptance.
+8 -1
View File
@@ -21,8 +21,15 @@ Use the command field in GaC-Srtool:
- `/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).
- `/faketime window <begin Unix seconds> <end Unix seconds>` keeps game time
inside a Client-observed overlap. It starts at the midpoint, advances, and
wraps to the beginning when the end is reached. Use only bounds verified for
the installed Client; changing Client revision requires checking them again.
The fake clock keeps advancing. Its offset is atomically saved to
The fake clock keeps advancing. Window mode wraps the shared game calendar for
all game features, so the displayed time moves backwards at the boundary and
other time-based activities may be affected. `/faketime off` or setting a new
single time disables the window. Its state 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
+7 -1
View File
@@ -19,6 +19,9 @@ type CurrencyWarsCommand struct {
Star uint32 `json:"star,omitempty"`
}
// CurrencyWarsCommandEnd requests the same settlement as quitting the run.
const CurrencyWarsCommandEnd = "end"
func ReadCurrencyWarsCommands() ([]CurrencyWarsCommand, error) {
root, err := readCurrencyWarsPersistentRoot()
if err != nil {
@@ -39,9 +42,12 @@ func QueueCurrencyWarsGrant(uid, runID uint32, field string, value, quantity, st
if field == "tb" || field == "trailblaze" {
field = "tbpower"
}
if uid == 0 || runID == 0 || quantity == 0 || (field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
if uid == 0 || runID == 0 || quantity == 0 || (field != CurrencyWarsCommandEnd && field != "gold" && field != "hp" && field != "exp" && field != "equip" && field != "role" && field != "orb" && field != "consumable" && field != "forge" && field != "tbpower") {
return "", fmt.Errorf("invalid CW command")
}
if field == CurrencyWarsCommandEnd && (value != 0 || quantity != 1 || star != 0) {
return "", fmt.Errorf("CW end does not accept arguments")
}
var nonce [16]byte
if _, err := rand.Read(nonce[:]); err != nil {
return "", err
+52 -16
View File
@@ -11,7 +11,9 @@ import (
// 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"`
OffsetMS int64 `json:"offset_ms"`
WindowBeginMS int64 `json:"window_begin_ms,omitempty"`
WindowEndMS int64 `json:"window_end_ms,omitempty"`
}
func gameClockPath() (string, error) {
@@ -41,6 +43,9 @@ func ReadGameClock() (GameClockState, error) {
if err := json.Unmarshal(raw, &state); err != nil {
return state, fmt.Errorf("game clock state: %w", err)
}
if (state.WindowBeginMS != 0 || state.WindowEndMS != 0) && (state.WindowBeginMS < 0 || state.WindowEndMS <= state.WindowBeginMS || state.WindowEndMS > 253402300799999) {
return state, fmt.Errorf("invalid game clock window")
}
return state, nil
}
@@ -54,17 +59,18 @@ func GameTime() (time.Time, error) {
return time.Time{}, fmt.Errorf("game clock outside supported calendar range")
}
ms := base + s.OffsetMS
if s.WindowEndMS != 0 {
span := s.WindowEndMS - s.WindowBeginMS
delta := (ms - s.WindowBeginMS) % span
if delta < 0 {
delta += span
}
ms = s.WindowBeginMS + delta
}
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 {
@@ -73,32 +79,62 @@ func SetGameTime(target *time.Time) (time.Time, error) {
}
state.OffsetMS = target.UnixMilli() - now.UnixMilli()
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
if err := saveGameClockState(state); err != nil {
return time.Time{}, err
}
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
}
// SetGameTimeWindow keeps the shared game calendar inside an observed Client
// interval. It wraps at its end; the host clock and operational timers remain real.
func SetGameTimeWindow(begin, end time.Time) (time.Time, error) {
start, finish := begin.UnixMilli(), end.UnixMilli()
if start < 0 || finish <= start || finish > 253402300799999 {
return time.Time{}, fmt.Errorf("invalid game clock window")
}
now := time.Now()
midpoint := start + (finish-start)/2
state := GameClockState{OffsetMS: midpoint - now.UnixMilli(), WindowBeginMS: start, WindowEndMS: finish}
if err := saveGameClockState(state); err != nil {
return time.Time{}, err
}
return time.UnixMilli(midpoint), nil
}
func saveGameClockState(state GameClockState) error {
path, err := gameClockPath()
if err != nil {
return err
}
if path == "" {
return fmt.Errorf("configure game_clock.state_file or GAC_GAME_CLOCK_STATE_FILE first")
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return err
}
raw, err := json.MarshalIndent(state, "", " ")
if err != nil {
return time.Time{}, err
return err
}
f, err := os.CreateTemp(filepath.Dir(path), ".game-clock-*.tmp")
if err != nil {
return time.Time{}, err
return err
}
name := f.Name()
defer os.Remove(name)
if _, err = f.Write(raw); err != nil {
f.Close()
return time.Time{}, err
return err
}
if err = f.Sync(); err != nil {
f.Close()
return time.Time{}, err
return err
}
if err = f.Close(); err != nil {
return time.Time{}, err
return err
}
if err = os.Rename(name, path); err != nil {
return time.Time{}, err
return err
}
return time.UnixMilli(now.UnixMilli() + state.OffsetMS), nil
return nil
}
+34
View File
@@ -42,6 +42,36 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
if string(before) != string(after) {
t.Fatal("read rewrote state")
}
begin := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
end := begin.Add(24 * time.Hour)
if _, err := SetGameTimeWindow(begin, end); err != nil {
t.Fatal(err)
}
window, err := ReadGameClock()
if err != nil || window.WindowBeginMS != begin.UnixMilli() || window.WindowEndMS != end.UnixMilli() {
t.Fatalf("window not saved: %+v %v", window, err)
}
// Simulate the clock having advanced beyond the end without waiting a day.
window.OffsetMS = end.Add(time.Hour).UnixMilli() - time.Now().UnixMilli()
raw, _ = json.Marshal(window)
if err := os.WriteFile(cfg.GameClock.StateFile, raw, 0600); err != nil {
t.Fatal(err)
}
now, err = GameTime()
if err != nil || now.Sub(begin.Add(time.Hour)) < 0 || now.Sub(begin.Add(time.Hour)) > time.Second {
t.Fatalf("window did not wrap: %v %v", now, err)
}
before, _ = os.ReadFile(cfg.GameClock.StateFile)
if _, err := GameTime(); err != nil {
t.Fatal(err)
}
after, _ = os.ReadFile(cfg.GameClock.StateFile)
if string(before) != string(after) {
t.Fatal("window read rewrote state")
}
if _, err := SetGameTimeWindow(end, begin); err == nil {
t.Fatal("invalid window accepted")
}
if _, err := SetGameTime(nil); err != nil {
t.Fatal(err)
}
@@ -49,6 +79,10 @@ func TestGameClockSharedStateAndReset(t *testing.T) {
if err != nil || time.Since(now).Abs() > time.Second {
t.Fatalf("real time not restored: %v %v", now, err)
}
window, err = ReadGameClock()
if err != nil || window.WindowBeginMS != 0 || window.WindowEndMS != 0 {
t.Fatalf("reset retained window: %+v %v", window, err)
}
if err := os.WriteFile(cfg.GameClock.StateFile, []byte(`{"offset_ms":9223372036854775807}`), 0600); err != nil {
t.Fatal(err)
}
+53
View File
@@ -157,6 +157,59 @@ func SaveAvatarRank(id, rank uint32) error {
return SaveAvatarProgress(id, nil, &rank)
}
// MaxAvatarProgress updates the latest roster under the shared save lock,
// preserving unknown fields and all non-avatar data.
func MaxAvatarProgress() (int, error) {
persistentWriteMu.Lock()
defer persistentWriteMu.Unlock()
unlock, err := lockPersistentFile()
if err != nil {
return 0, err
}
defer unlock()
raw, err := os.ReadFile("freesr-data.json")
if err != nil {
return 0, err
}
var root map[string]json.RawMessage
if err = json.Unmarshal(raw, &root); err != nil || root == nil {
return 0, fmt.Errorf("invalid freesr-data object")
}
var avatars map[string]json.RawMessage
if err = json.Unmarshal(root["avatars"], &avatars); err != nil || avatars == nil {
return 0, fmt.Errorf("invalid avatar roster")
}
for id, entry := range avatars {
var avatar map[string]json.RawMessage
if err = json.Unmarshal(entry, &avatar); err != nil || avatar == nil {
return 0, fmt.Errorf("invalid avatar %s", id)
}
var data map[string]json.RawMessage
if err = json.Unmarshal(avatar["data"], &data); err != nil || data == nil {
return 0, fmt.Errorf("invalid rank data for avatar %s", id)
}
avatar["level"] = json.RawMessage("80")
avatar["promotion"] = json.RawMessage("6")
data["rank"] = json.RawMessage("6")
avatar["data"], err = json.Marshal(data)
if err != nil {
return 0, err
}
avatars[id], err = json.Marshal(avatar)
if err != nil {
return 0, err
}
}
root["avatars"], err = json.Marshal(avatars)
if err != nil {
return 0, err
}
if err = writeJSONAtomic("freesr-data.json", root); err != nil {
return 0, err
}
return len(avatars), nil
}
// SaveAvatarProgress patches only the selected level and/or rank in the latest
// import, under the same cross-process lock used by inventory and gacha saves.
func SaveAvatarProgress(id uint32, level, rank *uint32) error {
+6 -2
View File
@@ -8,7 +8,7 @@ import (
// Inspect actual battle buffs, rather than ChallengeStageID or a reused stage ID.
// This covers an imported custom battle that already includes Corruption.
func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled bool, owner uint32) error {
func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled bool, owner uint32, attackerSlot *uint32) error {
var corruption uint32
for _, buff := range battle.BuffList {
for _, id := range r.InvasionBuffs {
@@ -57,7 +57,11 @@ func (r *battleRules) applyAhaHelp(battle *pb.SceneBattleInfo, legacyEnabled boo
}
battle.BuffList = filtered
if support != 0 {
battle.BuffList = append(battle.BuffList, battleBuff(support, owner, map[string]float32{"SkillIndex": 1}))
buff := battleBuff(support, owner, map[string]float32{"SkillIndex": 1})
if attackerSlot != nil {
buff.TargetIndexList = []uint32{*attackerSlot}
}
battle.BuffList = append(battle.BuffList, buff)
}
SceneDebugLog("AHA_HELP stage=%d corruption=%d selected=%v legacy=%t support=%d level=%d", battle.StageId, corruption, selectedValue(selected), legacyEnabled, support, level)
return nil
+134
View File
@@ -0,0 +1,134 @@
package gameserver
import (
"sort"
"testing"
"gac-hkrpg-go/internal/common"
"gac-hkrpg-go/internal/testutil"
pb "gac-hkrpg-go/protocol/proto"
)
func TestAhaBuffTargetsSceneAttacker(t *testing.T) {
temporary := t.TempDir()
if err := testutil.CopyRuntime(testWorkspaceRoot, temporary); err != nil {
t.Fatal(err)
}
t.Chdir(temporary)
t.Setenv("GAC_CONFIG", "server.json")
rules, err := loadBattleRules()
if err != nil {
t.Fatal(err)
}
resources, err := common.GameRes()
if err != nil {
t.Fatal(err)
}
world, err := loadWorldBattleResources()
if err != nil {
t.Fatal(err)
}
levels, err := common.LoadAhaHelpLevels()
if err != nil {
t.Fatal(err)
}
if err := common.SaveAhaHelpLevel(&levels[0].BuffLevel); err != nil {
t.Fatal(err)
}
// Pick valid characters and a battle source from the checked-in resources;
// neither the user's live roster nor their persistent settings are changed.
var ids []uint32
for id, config := range resources.AvatarConfigs {
if id != 8001 && id != 1001 && rules.EntryAttackBuffs[config.DamageType] != 0 {
ids = append(ids, id)
}
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) < 4 || len(world.Cocoons) == 0 || len(world.NPCMonsters) == 0 {
t.Fatal("battle fixture data incomplete")
}
player := &common.FreesrData{Avatars: map[uint32]*common.AvatarJson{}, Lineups: map[uint32]uint32{}}
for i, id := range ids[:4] {
player.Avatars[id] = &common.AvatarJson{AvatarID: id, Level: 1}
player.Lineups[uint32(i)] = id
}
enabled := true
player.EnableAhaGlobal, player.EnableSWGlobal, player.EnableCastoriceGlobal = &enabled, &enabled, &enabled
var monster, corruption, ahaID uint32
for id := range world.NPCMonsters {
monster = id
break
}
for _, id := range rules.InvasionBuffs {
corruption = id
break
}
for _, rule := range rules.GlobalPassives {
if rule.Setting == "enable_aha_global" {
ahaID = rule.BuffID
}
}
if monster == 0 || corruption == 0 || ahaID == 0 {
t.Fatal("battle fixture rule missing")
}
source := &common.BattleConfig{StageID: world.Cocoons[0].StageID, Monsters: [][]common.Monster{{{MonsterID: monster, Level: 1}}}, Blessings: []common.BattleBuffJson{{ID: corruption, Level: 1}}}
session := &Session{MonsterEntities: map[uint32]uint32{777777: monster}}
session.SetJSON(player)
cases := []struct {
name string
caster, want uint32
}{
{"first slot", 1, 0}, {"second slot", 2, 1}, {"third slot", 3, 2}, {"fourth slot", 4, 3},
{"avatar entity namespace", techniqueAvatarEntityOffset + ids[2], 2},
{"direct entry has no caster", 0, 0}, {"unknown actor", 12345, 0}, {"monster ambush", 777777, 0},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
battle, err := createBattleInfo(session, tc.caster, 0, source)
if err != nil {
t.Fatal(err)
}
var aha, help *pb.BattleBuff
for _, buff := range battle.BuffList {
switch buff.Id {
case ahaID:
aha = buff
case levels[0].BuffID:
help = buff
default:
for _, rule := range rules.GlobalPassives {
if rule.Setting != "enable_aha_global" && buff.Id == rule.BuffID && (len(buff.TargetIndexList) != 1 || buff.TargetIndexList[0] != 0) {
t.Fatal("unrelated passive target changed")
}
}
}
}
for name, buff := range map[string]*pb.BattleBuff{"Aha Global": aha, "Aha Help": help} {
if buff == nil || len(buff.TargetIndexList) != 1 || buff.TargetIndexList[0] != tc.want {
t.Fatalf("%s target: got %v, want slot %d", name, buff, tc.want)
}
}
// Source ownership and unrelated corruption are unchanged.
if help.OwnerId != battle.BattleAvatarList[0].Index {
t.Fatal("Aha Help owner changed")
}
})
}
off := uint32(0)
if err := common.SaveAhaHelpLevel(&off); err != nil {
t.Fatal(err)
}
battle, err := createBattleInfo(session, 2, 0, source)
if err != nil {
t.Fatal(err)
}
for _, buff := range battle.BuffList {
if buff.Id == levels[0].BuffID {
t.Fatal("disabled Aha Help was added")
}
}
}
+10 -2
View File
@@ -145,7 +145,7 @@ func (r *battleRules) techniqueBuffs(a *common.AvatarJson, slot, leader uint32,
return result
}
func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player *common.FreesrData) {
func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player *common.FreesrData, attackerSlot *uint32) {
for _, rule := range r.GlobalPassives {
var enabled *bool
switch rule.Setting {
@@ -170,7 +170,15 @@ func (r *battleRules) addGlobalPassives(battle *goproto.SceneBattleInfo, player
if !present {
owner = battle.BattleAvatarList[0].Index
}
battle.BuffList = append(battle.BuffList, battleBuff(rule.BuffID, owner, nil))
buff := battleBuff(rule.BuffID, owner, nil)
// Owner is the configured source of the passive; the target is the
// scene actor who started this battle. Other global passives retain
// their captured target. A request without an identified attacker
// keeps the existing wire representation.
if rule.Setting == "enable_aha_global" && attackerSlot != nil {
buff.TargetIndexList = []uint32{*attackerSlot}
}
battle.BuffList = append(battle.BuffList, buff)
}
}
+2 -2
View File
@@ -16,7 +16,7 @@ func cwArchiveSnapshot(c *common.CurrencyWarsCatalog) (*common.CurrencyWarsSnaps
return common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
}
func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC) error {
func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *common.CurrencyWarsCatalog, record *pb.OEDHMAHDECC, commandID ...string) error {
if record == nil || s.currencyWarsRevision >= math.MaxUint32 {
return fmt.Errorf("CW archive record/identifier invalid")
}
@@ -30,7 +30,7 @@ func (s *Session) saveCurrencyWarsTerminal(data *pb.GridFightGetDataScRsp, c *co
if err != nil {
return err
}
return s.saveCurrencyWarsSnapshot(data, c, nil, wire)
return s.saveCurrencyWarsSnapshot(data, c, nil, wire, commandID...)
}
func cwReadArchives(snapshot *common.CurrencyWarsSnapshot) ([]*pb.FAIHLAKPABN, error) {
+59 -3
View File
@@ -6,9 +6,6 @@ import (
)
func (s *Session) applyCurrencyWarsCommands() error {
if !s.currencyWarsOpen {
return nil
}
if _, active := CURRENT_BATTLE.Load(s.Token); active {
return nil
}
@@ -23,10 +20,20 @@ func (s *Session) applyCurrencyWarsCommands() error {
if err != nil {
return err
}
// A pending grant can be blocked by a stuck page. Give the explicit quit
// request priority so it can recover that run without applying the grant.
for _, command := range commands {
if command.UID == cfg.PlayerUID && command.Field == common.CurrencyWarsCommandEnd {
return s.applyCurrencyWarsEnd(command)
}
}
for _, command := range commands {
if command.UID != cfg.PlayerUID {
continue
}
if !s.currencyWarsOpen {
continue
}
catalog, data, err := cwPreparingData(s)
if err != nil {
return nil
@@ -62,3 +69,52 @@ func (s *Session) applyCurrencyWarsCommands() error {
}
return nil
}
// End is allowed even with a pending choice so it can recover a stuck CW UI.
// Do not end a battle or replace a newer session's saved data. Queue removal,
// archive and quit settlement share the same persistent CAS transaction.
func (s *Session) applyCurrencyWarsEnd(command common.CurrencyWarsCommand) error {
catalog, err := common.LoadCurrencyWarsCatalog()
if err != nil {
return err
}
data, revision, err := readCurrencyWarsData(catalog)
if err != nil {
return err
}
if data == nil || data.DHFOFLFCLAH == nil || data.DHFOFLFCLAH.UniqueId != command.RunID {
if err := common.AcknowledgeCurrencyWarsCommand(command.ID); err != nil {
return err
}
PacketLog(fmt.Sprintf("CW_GM discarded end request=%s: run already ended or changed", command.ID))
return nil
}
if revision != s.currencyWarsRevision {
// If this session has not opened CW, it holds no CW UI state. Adopt the
// freshly read revision; the CAS still rejects any concurrent changes.
if s.currencyWarsOpen {
return common.ErrCurrencyWarsSaveConflict
}
s.currencyWarsRevision = revision
}
keep, settle, err := currencyWarsQuitSettlement(catalog, data, data.DHFOFLFCLAH)
if err != nil {
return err
}
data.DHFOFLFCLAH = nil
if err := s.saveCurrencyWarsTerminal(data, catalog, settle.BFLLJJBHLDF, command.ID); err != nil {
return err
}
wasOpen := s.currencyWarsOpen
s.currencyWarsOpen = false
if wasOpen {
if err := cwSend(s, keep); err != nil {
return err
}
if err := cwSend(s, settle); err != nil {
return err
}
}
PacketLog(fmt.Sprintf("CW_GM applied end request=%s run=%d revision=%d result=quit", command.ID, command.RunID, s.currencyWarsRevision))
return nil
}
@@ -0,0 +1,294 @@
package gameserver
import (
"bytes"
"encoding/json"
"errors"
"os"
"testing"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func cwEndFixture(t *testing.T) (*common.CurrencyWarsCatalog, *pb.GridFightGetDataScRsp, *Session, uint32) {
t.Helper()
c := isolatedCWTest(t)
data, err := newCurrencyWarsData(c)
if err != nil {
t.Fatal(err)
}
request := &pb.GridFightStartGamePlayCsReq{DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID}
// Select a division actually present in the captured initial-run resource.
raw, err := common.ReadResourceFile("resources/CurrencyWarsInitialRuns.json")
if err != nil {
t.Fatal(err)
}
var resource struct {
Entries []struct {
Division uint32 `json:"division_id"`
Overclock bool `json:"is_overclock"`
} `json:"entries"`
}
if err := json.Unmarshal(raw, &resource); err != nil || len(resource.Entries) == 0 {
t.Fatalf("initial runs: %v", err)
}
request.DivisionId, request.IsOverlock = resource.Entries[0].Division, resource.Entries[0].Overclock
run, err := buildCurrencyWarsInitialRun(c, request, 7)
if err != nil {
t.Fatal(err)
}
offer := run.FMIGOKIDKJL.GetMFFCPLBNHCF()
if offer == nil || len(offer.NPHOIMEEDHM) == 0 {
t.Fatal("portal offer missing")
}
run, _, err = currencyWarsEntryChoice(c, run, run.FMIGOKIDKJL.QueuePosition, offer.NPHOIMEEDHM[0])
if err != nil {
t.Fatal(err)
}
data.DHFOFLFCLAH = run
s := &Session{Token: 444, currencyWarsOpen: true}
if err := s.saveCurrencyWarsData(data, c); err != nil {
t.Fatal(err)
}
cfg, err := common.LoadRuntimeConfig()
if err != nil {
t.Fatal(err)
}
return c, data, s, cfg.PlayerUID
}
func TestCurrencyWarsEndWaitsForBattleAndSettlesPendingRun(t *testing.T) {
c, data, s, uid := cwEndFixture(t)
runID := data.DHFOFLFCLAH.UniqueId
if data.DHFOFLFCLAH.FMIGOKIDKJL == nil {
t.Fatal("fixture must have a pending choice")
}
if err := common.PatchPersistentFields(map[string]json.RawMessage{"ex_ranks_unlocked": json.RawMessage(`{"1204":false}`), "unrelated": json.RawMessage(`{"keep":true}`)}); err != nil {
t.Fatal(err)
}
// An ordinary grant queued first must not prevent ending a stuck page.
if _, err := common.QueueCurrencyWarsCommand(uid, runID, "gold", 99); err != nil {
t.Fatal(err)
}
endID, err := common.QueueCurrencyWarsCommand(uid, runID, common.CurrencyWarsCommandEnd, 0)
if err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile("persistent")
CURRENT_BATTLE.Store(s.Token, &pb.SceneBattleInfo{})
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
CURRENT_BATTLE.Delete(s.Token)
after, _ := os.ReadFile("persistent")
if !bytes.Equal(before, after) {
t.Fatal("end ran while Battle was active")
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
saved, _, err := readCurrencyWarsData(c)
if err != nil || saved.DHFOFLFCLAH != nil {
t.Fatalf("run not ended: %v", err)
}
snapshot, err := cwArchiveSnapshot(c)
if err != nil {
t.Fatal(err)
}
archives, err := cwReadArchives(snapshot)
if err != nil || len(archives) != 1 {
t.Fatalf("archive: %v", err)
}
if archives[0].BFLLJJBHLDF.Reason != pb.PEIJNEFOCHI_PEIJNEFOCHI_NOCIJDINKPJ {
t.Fatal("end must settle as quit")
}
queued, err := common.ReadCurrencyWarsCommands()
if err != nil {
t.Fatal(err)
}
for _, cmd := range queued {
if cmd.ID == endID {
t.Fatal("end was not atomically acknowledged")
}
}
var root map[string]json.RawMessage
raw, _ := os.ReadFile("persistent")
if err := json.Unmarshal(raw, &root); err != nil {
t.Fatal(err)
}
var ex, unrelated map[string]bool
if err := json.Unmarshal(root["ex_ranks_unlocked"], &ex); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(root["unrelated"], &unrelated); err != nil {
t.Fatal(err)
}
if len(ex) != 1 || ex["1204"] || !unrelated["keep"] || len(unrelated) != 1 {
t.Fatal("unrelated state changed")
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
again, err := cwArchiveSnapshot(c)
if err != nil || len(again.Archives) != 1 {
t.Fatal("duplicate settlement")
}
}
func TestCurrencyWarsEndRejectsStaleAndDifferentRun(t *testing.T) {
c, data, s, uid := cwEndFixture(t)
runID := data.DHFOFLFCLAH.UniqueId
cmdID, err := common.QueueCurrencyWarsCommand(uid, runID, common.CurrencyWarsCommandEnd, 0)
if err != nil {
t.Fatal(err)
}
s.currencyWarsRevision--
before, _ := os.ReadFile("persistent")
if err := s.applyCurrencyWarsCommands(); !errors.Is(err, common.ErrCurrencyWarsSaveConflict) {
t.Fatalf("stale open session: %v", err)
}
after, _ := os.ReadFile("persistent")
if !bytes.Equal(before, after) {
t.Fatal("stale session changed save")
}
if err := common.AcknowledgeCurrencyWarsCommand(cmdID); err != nil {
t.Fatal(err)
}
if _, err := common.QueueCurrencyWarsCommand(uid, runID+1, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
saved, _, err := readCurrencyWarsData(c)
if err != nil || saved.DHFOFLFCLAH == nil || saved.DHFOFLFCLAH.UniqueId != runID {
t.Fatal("wrong-run command ended active run")
}
}
func TestCurrencyWarsEndWorksAfterLeavingCW(t *testing.T) {
c, data, s, uid := cwEndFixture(t)
if _, err := common.QueueCurrencyWarsCommand(uid, data.DHFOFLFCLAH.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
s.currencyWarsOpen = false
s.currencyWarsRevision = 0 // A newly connected session has not read CW yet.
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
saved, _, err := readCurrencyWarsData(c)
if err != nil || saved.DHFOFLFCLAH != nil {
t.Fatal("closed CW did not end")
}
}
func TestCurrencyWarsEndNotifiesOpenClient(t *testing.T) {
_, data, savedSession, uid := cwEndFixture(t)
s, receive := replySession(t)
s.currencyWarsRevision = savedSession.currencyWarsRevision
s.currencyWarsOpen = true
if _, err := common.QueueCurrencyWarsCommand(uid, data.DHFOFLFCLAH.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
if err := s.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
receive(&pb.GridFightSyncKeepWinCntNotify{})
settlement := &pb.GridFightSettleNotify{}
receive(settlement)
if settlement.BFLLJJBHLDF == nil || settlement.BFLLJJBHLDF.Reason != pb.PEIJNEFOCHI_PEIJNEFOCHI_NOCIJDINKPJ {
t.Fatal("client did not receive quit settlement")
}
}
func TestCurrencyWarsEndDoesNotCommitIfCommandIsMissing(t *testing.T) {
_, data, s, uid := cwEndFixture(t)
before, _ := os.ReadFile("persistent")
cmd := common.CurrencyWarsCommand{ID: "missing", UID: uid, RunID: data.DHFOFLFCLAH.UniqueId, Field: common.CurrencyWarsCommandEnd}
if err := s.applyCurrencyWarsEnd(cmd); err == nil {
t.Fatal("missing command should reject settlement")
}
after, _ := os.ReadFile("persistent")
if !bytes.Equal(before, after) {
t.Fatal("failed acknowledgement committed settlement or archive")
}
}
func TestCurrencyWarsOpenStateTracksStartLeaveAndResume(t *testing.T) {
_, data, writer, uid := cwEndFixture(t)
previous := data.DHFOFLFCLAH
if _, err := common.QueueCurrencyWarsCommand(uid, previous.UniqueId, common.CurrencyWarsCommandEnd, 0); err != nil {
t.Fatal(err)
}
writer.currencyWarsOpen = false
if err := writer.applyCurrencyWarsCommands(); err != nil {
t.Fatal(err)
}
s, receive := replySession(t)
s.currencyWarsRevision = writer.currencyWarsRevision
req := &pb.GridFightStartGamePlayCsReq{DivisionId: previous.DivisionId, IsOverlock: previous.IsOverlock, LJNGPCKPJLF: previous.LJNGPCKPJLF}
callReviewRequest(t, s, req)
start := &pb.GridFightStartGamePlayScRsp{}
receive(start)
if start.Retcode != 0 || start.DHFOFLFCLAH == nil || !s.currencyWarsOpen {
t.Fatal("successful start did not mark CW open")
}
callReviewRequest(t, s, &pb.GridFightQuitLeaveGamePlayCsReq{})
receive(&pb.GridFightQuitLeaveGamePlayScRsp{})
if s.currencyWarsOpen {
t.Fatal("leave did not close CW")
}
callReviewRequest(t, s, &pb.GridFightResumeGamePlayCsReq{UniqueId: start.DHFOFLFCLAH.UniqueId})
resume := &pb.GridFightResumeGamePlayScRsp{}
receive(resume)
if resume.Retcode != 0 || !s.currencyWarsOpen {
t.Fatal("successful resume did not mark CW open")
}
}
func TestCurrencyWarsInitialRoundReservesHigherStarStarter(t *testing.T) {
c, data, _, _ := cwEndFixture(t)
run := data.DHFOFLFCLAH
_, team, shop := cwComponents(run)
if team == nil || shop == nil || len(team.GridGameRoleList) == 0 {
t.Fatal("starter team fixture missing")
}
pool, err := c.NewCapturedCardPool()
if err != nil {
t.Fatal(err)
}
var selected common.CurrencyWarsRoleVariant
for _, v := range c.CapturedVariants {
base, err := c.CapturedRole(v.Config.ID)
if err != nil || v.Star <= base.DefaultStar {
continue
}
if _, present := pool.Roles[v.Config.ID]; present {
selected = v
break
}
}
if selected.Config.ID == 0 {
t.Fatal("higher-star pool role missing")
}
// The live failure occurs after a portal has supplied a non-default star.
team.GridGameRoleList = []*pb.CDEAABODKCP{{Id: selected.Config.ID, RoleStar: selected.Star, UniqueId: 1, Pos: 1}}
if run.FMIGOKIDKJL.GetNNDGEJFLGGF() == nil || len(shop.OGDKJPDFFAI) != 0 {
t.Fatal("fixture is not at initial round")
}
invalid := proto.Clone(run).(*pb.MPIMAEMCEFH)
_, invalidTeam, _ := cwComponents(invalid)
invalidTeam.GridGameRoleList[0].RoleStar = ^uint32(0)
before := proto.Clone(invalid)
if _, err := cwInitialRound(c, invalid); err == nil || !proto.Equal(before, invalid) {
t.Fatal("invalid starter star was accepted or changed state")
}
if _, err := cwInitialRound(c, run); err != nil {
t.Fatalf("higher-star starter rejected: %v", err)
}
if run.FMIGOKIDKJL.GetLGNPHHBGKFH() == nil || len(shop.OGDKJPDFFAI) == 0 {
t.Fatal("initial round did not advance to preparation/shop")
}
}
@@ -79,19 +79,20 @@ func cwInitialRound(catalog *common.CurrencyWarsCatalog, run *pb.MPIMAEMCEFH) (*
}
// Reserve starter copies from the same finite pool before drawing the shop.
for _, role := range team.GridGameRoleList {
r, ok := pool.Roles[role.Id]
if !ok {
if role == nil {
return nil, fmt.Errorf("CW starter role is missing")
}
if _, ok := pool.Roles[role.Id]; !ok {
continue
}
config, e := catalog.CapturedRole(role.Id)
if e != nil {
return nil, e
if _, err := catalog.CapturedRoleVariant(role.Id, role.RoleStar); err != nil {
return nil, fmt.Errorf("CW starter role %d star %d not captured: %w", role.Id, role.RoleStar, err)
}
if role.RoleStar != config.DefaultStar || r.Remaining == 0 {
return nil, fmt.Errorf("CW starter pool reservation is not supported")
// Reserve the number of base copies represented by this star, using
// the same captured merge rules as later shop restocks.
if err := pool.ReserveOwned(catalog, role.Id, role.RoleStar); err != nil {
return nil, fmt.Errorf("CW starter role %d star %d: %w", role.Id, role.RoleStar, err)
}
r.Remaining--
pool.Roles[role.Id] = r
}
level, err := catalog.PlayerLevel(player.EHNJDIDABHD)
if err != nil {
+7 -1
View File
@@ -164,7 +164,11 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
// 1. empty-dummy responses
if rspID, ok := dummyResponses[cmdID]; ok {
_ = s.SendRaw(&NetPacket{CmdType: rspID})
if err := s.SendRaw(&NetPacket{CmdType: rspID}); err != nil {
Log(fmt.Sprintf("[DISPATCH] response %d (%s) failed: %v", rspID, goproto.NameByCmdID[rspID], err))
} else {
Log(fmt.Sprintf("[PACKET] queued empty response cmd_id: %d (%s)", rspID, goproto.NameByCmdID[rspID]))
}
return
}
@@ -172,6 +176,7 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
if h, ok := rawHandlers[cmdID]; ok {
if err := h(s, payload); err != nil {
PacketLog("[ERROR] raw handler " + fmt.Sprint(cmdID) + ": " + err.Error())
Log(fmt.Sprintf("[DISPATCH] raw handler %d failed: %v", cmdID, err))
}
return
}
@@ -180,6 +185,7 @@ func DispatchMessage(s *Session, cmdID uint16, payload []byte) {
if h, ok := typedHandlers[cmdID]; ok {
if err := h(s, payload); err != nil {
PacketLog("[ERROR] handler cmd " + fmt.Sprint(cmdID) + ": " + err.Error())
Log(fmt.Sprintf("[DISPATCH] handler %d failed: %v", cmdID, err))
}
return
}
+2
View File
@@ -84,6 +84,7 @@ func (g *Gateway) Listen() error {
if n == 20 {
if err := g.processNetOperation(append([]byte(nil), buf[:n]...), addr); err != nil {
PacketLog("[ERROR] netop: " + err.Error())
Log("[KCP] handshake rejected: " + err.Error())
}
} else if n >= 28 {
g.processKCPPayload(append([]byte(nil), buf[:n]...), addr)
@@ -208,6 +209,7 @@ func (g *Gateway) processKCPPayload(data []byte, addr *net.UDPAddr) {
// same-conv replays from a stale client get rejected by the token check
if err := session.Consume(data); err != nil {
PacketLog("[ERROR] Session consume error (" + addr.String() + "): " + err.Error())
Log("[KCP] packet rejected: " + err.Error())
}
}
+6 -4
View File
@@ -457,9 +457,12 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
// actor from the scene's actual slot IDs, rather than assuming cast_entity
// identifies a lineup position.
attackSlot, attackID := firstAvatarIdx, firstAvatarID
var attackerSlot *uint32
_, attackedByMonster := s.MonsterEntities[casterID]
for _, avatar := range battleInfo.BattleAvatarList {
if casterID != 0 && avatar.Index+1 == casterID {
if !attackedByMonster && casterID != 0 && (avatar.Index+1 == casterID || (casterID >= techniqueAvatarEntityOffset && avatar.Id == casterID-techniqueAvatarEntityOffset)) {
attackSlot, attackID = avatar.Index, avatar.Id
attackerSlot = &attackSlot
break
}
}
@@ -472,7 +475,6 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
if attackBuffID == 0 {
attackBuffID = cfg.WeaknessBuffID
}
_, attackedByMonster := s.MonsterEntities[casterID]
if attackedByMonster {
// An ambush has no player entry attack.
} else if attackBuffID != 0 {
@@ -550,7 +552,7 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
battleInfo.MonsterWaveList = common.ToSceneMonsterWaves(player.BattleConfig.Monsters)
battleInfo.JAOIOKBBMFC = uint32(len(battleInfo.MonsterWaveList))
rules.addGlobalPassives(battleInfo, player)
rules.addGlobalPassives(battleInfo, player, attackerSlot)
// Official challenges add their resource-defined corruption. Custom battles
// retain only the corruption actually supplied in their buff list.
@@ -563,7 +565,7 @@ func createBattleInfo(s *Session, casterID, skillIndex uint32, sources ...*commo
battleInfo.BuffList = append(battleInfo.BuffList, battleBuff(buffID, firstAvatarIdx, map[string]float32{"SkillIndex": 1}))
}
}
if err := rules.applyAhaHelp(battleInfo, player.BattleConfig.EnableInvasionSupport, firstAvatarIdx); err != nil {
if err := rules.applyAhaHelp(battleInfo, player.BattleConfig.EnableInvasionSupport, firstAvatarIdx, attackerSlot); err != nil {
return nil, err
}
if len(battleInfo.MonsterWaveList) == 0 {
@@ -95,6 +95,7 @@ func init() {
return nil
}
rsp.DHFOFLFCLAH = proto.Clone(run).(*goproto.MPIMAEMCEFH)
s.currencyWarsOpen = true
rsp.Retcode = 0
PacketLog(fmt.Sprintf("CW_START_GAMEPLAY started mode=%s division=%d season=%d run=%d revision=%d source=captured_initial_preset", mode, req.DivisionId, req.LJNGPCKPJLF, run.UniqueId, s.currencyWarsRevision))
return nil
@@ -159,6 +160,7 @@ func init() {
}
}
}
s.currencyWarsOpen = true
rsp.Retcode = 0
return nil
})
+16 -3
View File
@@ -77,9 +77,22 @@ func init() {
if player == nil {
return nil
}
// The imported banner lists target another Client. Reject draws until
// their pools, grants and protocol fields are verified for this Client.
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT)
mapped, _, err := common.LoadLimitedGachaBanners()
if err != nil {
return err
}
for _, banner := range mapped {
if banner.GachaID == req.GachaId {
return drawLimitedAvatar(s, req, rsp, banner)
}
}
drawn, err := drawSandboxPool(s, req, rsp)
if err != nil {
return err
}
if !drawn {
rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT)
}
return nil
})
+5 -2
View File
@@ -25,12 +25,15 @@ func TestClientRelicCatalogFiltersUnsupportedInventoryEntries(t *testing.T) {
{RelicID: validID, InternalUID: 1},
{RelicID: 71011, InternalUID: 2},
{RelicID: 63295, InternalUID: 3},
{RelicID: 63296, InternalUID: 4},
{RelicID: 63305, InternalUID: 5},
{RelicID: 63306, InternalUID: 6},
})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].Tid != validID || omitted != 2 {
t.Fatalf("filtered Relics = %+v, omitted %d; want only Client-supported ID %d", got, omitted, validID)
if len(got) != 5 || got[0].Tid != validID || omitted != 1 {
t.Fatalf("filtered Relics = %+v, omitted %d; want five Client-supported IDs", got, omitted)
}
}
@@ -0,0 +1,66 @@
package gameserver
import (
"encoding/json"
"os"
"testing"
)
func TestCapturedPlanarRelicAffixesMatchCatalog(t *testing.T) {
chdirRepositoryRoot(t)
raw, err := os.ReadFile("resources/RelicClientCapture.json")
if err != nil {
t.Fatal(err)
}
var capture struct {
Relics []struct{ ID, SetID, MainAffixGroup, SubAffixGroup uint32 } `json:"relics"`
Main []struct{ GroupID, AffixID, Property uint32 } `json:"main_affixes"`
Sub []struct{ GroupID, AffixID, Property uint32 } `json:"sub_affixes"`
}
if err := json.Unmarshal(raw, &capture); err != nil {
t.Fatal(err)
}
if _, err := loadInventoryItems(); err != nil {
t.Fatal(err)
}
if len(capture.Relics) != 4 {
t.Fatalf("captured %d relics", len(capture.Relics))
}
for _, row := range capture.Relics {
var found bool
for _, item := range inventoryCatalog.Relics {
if item.RelicID != row.ID {
continue
}
found = true
if item.RelicSetID != row.SetID {
t.Fatalf("relic %d has wrong set", row.ID)
}
var mainProperty uint32
for _, main := range capture.Main {
if main.GroupID == row.MainAffixGroup && main.AffixID == item.MainAffixID {
mainProperty = main.Property
}
}
if mainProperty == 0 {
t.Fatalf("relic %d main affix is outside its group", row.ID)
}
seen := make(map[uint32]bool)
for _, affix := range item.SubAffixes {
valid := false
for _, sub := range capture.Sub {
if sub.GroupID == row.SubAffixGroup && sub.AffixID == affix.SubAffixID && sub.Property != mainProperty {
valid = true
}
}
if !valid || seen[affix.SubAffixID] {
t.Fatalf("relic %d has invalid or duplicate sub affix %d", row.ID, affix.SubAffixID)
}
seen[affix.SubAffixID] = true
}
}
if !found {
t.Fatalf("relic %d missing from catalog", row.ID)
}
}
}
+20 -4
View File
@@ -35,8 +35,9 @@ type Session struct {
stateMu sync.Mutex // serializes packet handlers and hot reload mutations
json *common.FreesrData
kcpMu sync.Mutex
kcp *kcplib.KCP
kcpMu sync.Mutex
kcp *kcplib.KCP
levelRewardAwaitingAck bool // guarded by kcpMu
startTime time.Time
nextSceneSave int64
@@ -129,6 +130,10 @@ func (s *Session) Consume(data []byte) error {
s.kcpMu.Unlock()
return err
}
if s.levelRewardAwaitingAck && s.kcp.PendingSends() == 0 {
Log("[KCP] outstanding responses including level rewards acknowledged")
s.levelRewardAwaitingAck = false
}
if err := s.kcp.Update(s.sessionTimeMs()); err != nil {
s.kcpMu.Unlock()
return err
@@ -141,9 +146,13 @@ func (s *Session) Consume(data []byte) error {
if err != nil {
break
}
pkt, err := DecodeNetPacket(buf[:n])
// DecodeNetPacket returns slices backed by its input. Keep each frame
// independent before the next Recv overwrites the shared buffer.
frame := append([]byte(nil), buf[:n]...)
pkt, err := DecodeNetPacket(frame)
if err != nil {
PacketLog(fmt.Sprintf("[ERROR] bad packet: %v", err))
Log(fmt.Sprintf("[PACKET] invalid frame: %v", err))
continue
}
packets = append(packets, pkt)
@@ -213,7 +222,14 @@ func (s *Session) SendRaw(pkt *NetPacket) error {
if _, err := s.kcp.Send(payload); err != nil {
return err
}
return s.kcp.Flush()
if err := s.kcp.Flush(); err != nil {
return err
}
if pkt.CmdType == uint16(goproto.CmdIDByName["GetLevelRewardTakenListScRsp"]) {
s.levelRewardAwaitingAck = s.kcp.PendingSends() > 0
Log(fmt.Sprintf("[KCP] level rewards response queued; pending segments=%d", s.kcp.PendingSends()))
}
return nil
}
// DriveUpdate is called by the gateway ticker to run retransmissions.
+105
View File
@@ -0,0 +1,105 @@
package gameserver
import (
"bytes"
"encoding/binary"
"testing"
kcplib "gac-hkrpg-go/pkg/kcp"
)
// A single UDP datagram can contain multiple complete KCP messages. Each
// handler must receive its own payload even when the receive buffer is reused.
func TestConsumeKeepsBatchedPacketBodiesIndependent(t *testing.T) {
const firstID, secondID uint16 = 65000, 65001
for _, id := range []uint16{firstID, secondID} {
if _, ok := rawHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := typedHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := dummyResponses[id]; ok {
t.Fatalf("test command %d has dummy response", id)
}
}
got := map[uint16]string{}
rawHandlers[firstID] = func(_ *Session, body []byte) error { got[firstID] = string(body); return nil }
rawHandlers[secondID] = func(_ *Session, body []byte) error { got[secondID] = string(body); return nil }
defer delete(rawHandlers, firstID)
defer delete(rawHandlers, secondID)
const conv, token uint32 = 77, 88
segment := func(seq uint32, frame []byte) []byte {
out := make([]byte, kcplib.Overhead+len(frame))
binary.LittleEndian.PutUint32(out[0:], conv)
binary.LittleEndian.PutUint32(out[4:], token)
out[8] = kcplib.CmdPush
binary.LittleEndian.PutUint16(out[10:], 128)
binary.LittleEndian.PutUint32(out[16:], seq)
binary.LittleEndian.PutUint32(out[24:], uint32(len(frame)))
copy(out[kcplib.Overhead:], frame)
return out
}
first := (&NetPacket{CmdType: firstID, Body: []byte("first")}).Encode()
second := (&NetPacket{CmdType: secondID, Body: []byte("other")}).Encode()
datagram := append(segment(0, first), segment(1, second)...)
s := NewSession(func([]byte) error { return nil }, conv, token)
defer s.Close()
if err := s.Consume(datagram); err != nil {
t.Fatal(err)
}
if got[firstID] != "first" || got[secondID] != "other" {
t.Fatalf("batched payloads corrupted: %#v", got)
}
}
func TestConsumeOutOfOrderFragmentsAndFollowingPacket(t *testing.T) {
const firstID, secondID uint16 = 65002, 65003
for _, id := range []uint16{firstID, secondID} {
if _, ok := rawHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := typedHandlers[id]; ok {
t.Fatalf("test handler %d already registered", id)
}
if _, ok := dummyResponses[id]; ok {
t.Fatalf("test command %d has dummy response", id)
}
}
got := map[uint16][]byte{}
rawHandlers[firstID] = func(_ *Session, body []byte) error { got[firstID] = append([]byte(nil), body...); return nil }
rawHandlers[secondID] = func(_ *Session, body []byte) error { got[secondID] = append([]byte(nil), body...); return nil }
defer delete(rawHandlers, firstID)
defer delete(rawHandlers, secondID)
const conv, token uint32 = 77, 88
segment := func(seq uint32, fragment byte, frame []byte) []byte {
out := make([]byte, kcplib.Overhead+len(frame))
binary.LittleEndian.PutUint32(out[0:], conv)
binary.LittleEndian.PutUint32(out[4:], token)
out[8], out[9] = kcplib.CmdPush, fragment
binary.LittleEndian.PutUint16(out[10:], 128)
binary.LittleEndian.PutUint32(out[16:], seq)
binary.LittleEndian.PutUint32(out[24:], uint32(len(frame)))
copy(out[kcplib.Overhead:], frame)
return out
}
payload := bytes.Repeat([]byte("first-fragment-"), 120)
frame := (&NetPacket{CmdType: firstID, Body: payload}).Encode()
following := (&NetPacket{CmdType: secondID, Body: []byte("next")}).Encode()
s := NewSession(func([]byte) error { return nil }, conv, token)
defer s.Close()
if err := s.Consume(segment(1, 0, frame[800:])); err != nil {
t.Fatal(err)
}
if len(got) != 0 {
t.Fatal("dispatched an incomplete out-of-order message")
}
combined := append(segment(0, 1, frame[:800]), segment(2, 0, following)...)
if err := s.Consume(combined); err != nil {
t.Fatal(err)
}
if !bytes.Equal(got[firstID], payload) || string(got[secondID]) != "next" {
t.Fatal("fragmented or following packet body was corrupted")
}
}
+75 -8
View File
@@ -2,13 +2,13 @@ package gameserver
import (
"encoding/binary"
"strings"
"sync"
"testing"
"time"
"google.golang.org/protobuf/proto"
"gac-hkrpg-go/internal/common"
kcplib "gac-hkrpg-go/pkg/kcp"
goproto "gac-hkrpg-go/protocol/proto"
)
@@ -36,21 +36,25 @@ func TestGatewayHandshakeAndLogin(t *testing.T) {
}
t.Logf("handshake ok: conv=%d token=%d", conv, token)
// 2. configure the SERVER-side session held by the gateway
player, _ := common.LoadFreesrData()
if player == nil {
player = &common.FreesrData{}
}
// 2. Verify the gateway loaded the player data itself.
serverSession := gw.GetSession(conv)
if serverSession == nil {
t.Fatal("gateway did not register the session")
}
serverSession.SetJSON(player)
if serverSession.JSON() == nil {
t.Fatal("gateway session has no player data")
}
// 3. send PlayerGetTokenCsReq (cmd 5)
reqID := uint16(goproto.CmdIDByName["PlayerGetTokenCsReq"])
rspID := uint16(goproto.CmdIDByName["PlayerGetTokenScRsp"])
if err := client.sendCmd(reqID, nil); err != nil {
// Synthetic fields exercise non-empty requests without storing account data.
tokenRequest := &goproto.PlayerGetTokenCsReq{AccountUid: "52022", Token: strings.Repeat("x", 32), Uid: playerUID, Platform: 3}
tokenBody, err := proto.Marshal(tokenRequest)
if err != nil {
t.Fatal(err)
}
if err := client.sendCmd(reqID, tokenBody); err != nil {
t.Fatalf("send cmd: %v", err)
}
@@ -71,6 +75,69 @@ func TestGatewayHandshakeAndLogin(t *testing.T) {
}
t.Logf("login token ok: retcode=%d uid=%d msg=%q", rsp.Retcode, rsp.Uid, rsp.Msg)
// 4. Login must also round-trip through the same UDP/KCP session.
loginReq := &goproto.PlayerLoginCsReq{LoginRandom: 123456, Signature: strings.Repeat("x", 768)}
loginBody, err := proto.Marshal(loginReq)
if err != nil {
t.Fatal(err)
}
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginCsReq"]), loginBody); err != nil {
t.Fatalf("send player login: %v", err)
}
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no player login response: %v", err)
}
if want := uint16(goproto.CmdIDByName["PlayerLoginScRsp"]); cmdID != want {
t.Fatalf("login response command: got %d want %d", cmdID, want)
}
loginRsp := &goproto.PlayerLoginScRsp{}
if err := proto.Unmarshal(body, loginRsp); err != nil {
t.Fatalf("decode login response: %v", err)
}
if loginRsp.Retcode != 0 || loginRsp.LoginRandom != loginReq.LoginRandom || loginRsp.BasicInfo == nil {
t.Fatalf("invalid player login response: %v", loginRsp)
}
// The Android client asks for level rewards before login finish.
if err := client.sendCmd(uint16(goproto.CmdIDByName["GetLevelRewardTakenListCsReq"]), nil); err != nil {
t.Fatalf("send level rewards request: %v", err)
}
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no level rewards response: %v", err)
}
if want := uint16(goproto.CmdIDByName["GetLevelRewardTakenListScRsp"]); cmdID != want {
t.Fatalf("level rewards response command: got %d want %d", cmdID, want)
}
levelRsp := &goproto.GetLevelRewardTakenListScRsp{}
if err := proto.Unmarshal(body, levelRsp); err != nil || levelRsp.Retcode != 0 {
t.Fatalf("invalid level rewards response: %v / %v", levelRsp, err)
}
if err := client.sendCmd(uint16(goproto.CmdIDByName["PlayerLoginFinishCsReq"]), nil); err != nil {
t.Fatalf("send login finish: %v", err)
}
seenFinish := false
for i := 0; i < 3 && !seenFinish; i++ {
cmdID, body, err = client.recvCmd(3 * time.Second)
if err != nil {
t.Fatalf("no login finish response: %v", err)
}
if cmdID == uint16(goproto.CmdIDByName["PlayerLoginFinishScRsp"]) {
finishRsp := &goproto.PlayerLoginFinishScRsp{}
if err := proto.Unmarshal(body, finishRsp); err != nil || finishRsp.Retcode != 0 {
t.Fatalf("invalid login finish response: %v / %v", finishRsp, err)
}
seenFinish = true
} else if cmdID != uint16(goproto.CmdIDByName["ContentPackageSyncDataScNotify"]) {
t.Fatalf("unexpected login finish packet: %d", cmdID)
}
}
if !seenFinish {
t.Fatal("login finish response missing")
}
// 4. exercise the SceneCastSkill fix: attack with no real monsters ->
// must NOT enter battle (no battle_info in the response)
skillReqID := uint16(goproto.CmdIDByName["SceneCastSkillCsReq"])
+32 -31
View File
@@ -835,35 +835,42 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
success = false
case "/max":
for _, av := range data.Avatars {
av.Level = 80
av.Promotion = 6
av.Data.Rank = 6
count, err := common.MaxAvatarProgress()
if err != nil {
output, success = "Cannot update avatars: "+err.Error(), false
} else {
output = fmt.Sprintf("All %d avatars saved at Level 80, Rank 6. Reconnect the game to load the change.", count)
}
output = fmt.Sprintf("[OK] All %d avatars set to Level 80, Rank 6.", len(data.Avatars))
case "/teleport", "/scene":
if len(parts) > 1 {
planeID, _ := strconv.ParseUint(parts[1], 10, 32)
if planeID != 0 {
data.Scene.PlaneID = uint32(planeID)
output = fmt.Sprintf("[OK] Teleported to PlaneID %d.", planeID)
}
if len(parts) > 2 {
floorID, _ := strconv.ParseUint(parts[2], 10, 32)
if floorID != 0 {
data.Scene.FloorID = uint32(floorID)
}
}
} else {
if len(parts) < 2 || len(parts) > 3 {
output = "Usage: /teleport <plane_id> [floor_id]"
success = false
break
}
planeID, parseErr := strconv.ParseUint(parts[1], 10, 32)
if parseErr != nil || planeID == 0 {
output, success = "Invalid plane ID.", false
break
}
var floorID uint64
if len(parts) == 3 {
floorID, parseErr = strconv.ParseUint(parts[2], 10, 32)
if parseErr != nil || floorID == 0 {
output, success = "Invalid floor ID.", false
break
}
}
data.Scene.PlaneID = uint32(planeID)
if floorID != 0 {
data.Scene.FloorID = uint32(floorID)
}
output = fmt.Sprintf("Scene saved for plane %d. Reconnect the game to load the change.", planeID)
case "/clear":
if len(parts) > 1 && parts[1] == "lineup" {
data.Lineups = make(map[uint32]uint32)
output = "[OK] Active lineup cleared."
if len(parts) == 2 && strings.EqualFold(parts[1], "lineup") {
output = "The active lineup cannot be empty. Change your team in the game."
success = false
} else {
output = "Usage: /clear lineup"
success = false
@@ -989,8 +996,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
}
case "/help":
output = "/faketime [status|off|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Set scene\n /avatar <id> <level> [rank] - Modify avatar\n /clear lineup - Clear active lineup slots\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations"
output = "/faketime [status|off|window <begin Unix seconds> <end Unix seconds>|Unix seconds|RFC3339 with timezone] - Set game calendar time\n"
output += "Available GM Commands:\n /max - Set all avatars to Max Lv.80 E6\n /teleport <plane_id> [floor_id] - Set scene\n /avatar <id> <level> [rank] - Modify avatar\n /unlockall warp [all|avatar|lightcone|off] - Toggle unlocked gacha banners\n /reload banner - Reload banner configurations"
default:
output = fmt.Sprintf("Unknown command: %s", cmd)
@@ -1007,14 +1014,8 @@ func apiCommand(w http.ResponseWriter, r *http.Request) {
output = err.Error()
}
}
} else if success && rootCmd != "/help" && rootCmd != "/avatar" {
raw, err := json.MarshalIndent(data, "", " ")
if err == nil {
err = os.WriteFile("freesr-data.json", raw, 0o644)
}
if err == nil {
err = data.SavePersistentChecked()
}
} else if success && rootCmd != "/help" && rootCmd != "/avatar" && rootCmd != "/max" {
err := data.SavePersistentChecked()
if err != nil {
success = false
output = err.Error()
+14 -1
View File
@@ -14,7 +14,7 @@ import (
func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
fail := func(err error) { writeJSON(w, map[string]any{"success": false, "output": err.Error()}) }
parts := strings.Fields(command)
usage := "Usage: /cw status | /cw list role|equip|orb|consumable|forge | /cw gold <total> | /cw hp <total> | /cw exp <gain> | /cw tbpower <total> [costed] | /cw equip|orb|consumable|forge <itemID> [count] (for orb: <itemID> or 'random') | /cw role <roleID> <star> [count]. Changes apply in CW Preparation."
usage := "Usage: /cw status | /cw end | /cw list role|equip|orb|consumable|forge | /cw gold <total> | /cw hp <total> | /cw exp <gain> | /cw tbpower <total> [costed] | /cw equip|orb|consumable|forge <itemID> [count] (for orb: <itemID> or 'random') | /cw role <roleID> <star> [count]. Changes apply in CW Preparation."
if len(parts) < 2 {
fail(fmt.Errorf("%s", usage))
return
@@ -132,6 +132,19 @@ func apiCurrencyWarsCommand(w http.ResponseWriter, command string) {
return
}
field := strings.ToLower(parts[1])
if field == common.CurrencyWarsCommandEnd {
if len(parts) != 2 {
fail(fmt.Errorf("Usage: /cw end"))
return
}
id, err := common.QueueCurrencyWarsCommand(cfg.PlayerUID, run.UniqueId, common.CurrencyWarsCommandEnd, 0)
if err != nil {
fail(err)
return
}
writeJSON(w, map[string]any{"success": true, "state": "queued", "request_id": id, "output": fmt.Sprintf("Queued CW end for run %d. The game server will settle it as a quit on heartbeat after any active Battle; this is not a victory. Keep the game connected and check /cw status or CW_GM logs.", run.UniqueId)})
return
}
if field == "tb" || field == "trailblaze" {
field = "tbpower"
}
@@ -0,0 +1,67 @@
package sdkserver
import (
"encoding/json"
"net/http/httptest"
"os"
"strings"
"testing"
"gac-hkrpg-go/internal/common"
pb "gac-hkrpg-go/protocol/proto"
"google.golang.org/protobuf/proto"
)
func TestCurrencyWarsEndCommandQueuesOnlyActiveRun(t *testing.T) {
chdirRepoRootSDK(t)
t.Setenv("GAC_CONFIG", "server.json")
if err := os.WriteFile("persistent", []byte(`{}`), 0600); err != nil {
t.Fatal(err)
}
c, err := common.LoadCurrencyWarsCatalog()
if err != nil {
t.Fatal(err)
}
cfg, err := common.LoadRuntimeConfig()
if err != nil {
t.Fatal(err)
}
data := &pb.GridFightGetDataScRsp{DHFOFLFCLAH: &pb.MPIMAEMCEFH{UniqueId: 5, DivisionId: c.CapturedStages[0].ID, LJNGPCKPJLF: c.ActiveSeasonID, NAGDJGMMCBE: []*pb.GridFightGameInfo{{LEOIAOFBBFA: &pb.GridFightGameInfo_FAOCFBOFCJP{FAOCFBOFCJP: &pb.NIHNGPKHEPC{}}}}}}
wire, err := proto.Marshal(data)
if err != nil {
t.Fatal(err)
}
snapshot := common.NewCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256, wire)
if _, err := common.CommitCurrencyWarsSnapshot(0, snapshot); err != nil {
t.Fatal(err)
}
invoke := func(command string) map[string]any {
t.Helper()
w := httptest.NewRecorder()
apiCurrencyWarsCommand(w, command)
var result map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result
}
if result := invoke("/cw end now"); result["success"] != false {
t.Fatal("end accepted extra arguments")
}
queued, err := common.ReadCurrencyWarsCommands()
if err != nil || len(queued) != 0 {
t.Fatal("invalid request queued a command")
}
result := invoke("/cw end")
if result["success"] != true || result["state"] != "queued" || !strings.Contains(result["output"].(string), "quit") {
t.Fatalf("unexpected response: %v", result)
}
queued, err = common.ReadCurrencyWarsCommands()
if err != nil || len(queued) != 1 || queued[0].UID != cfg.PlayerUID || queued[0].RunID != data.DHFOFLFCLAH.UniqueId || queued[0].Field != common.CurrencyWarsCommandEnd {
t.Fatalf("unexpected queue: %v %v", queued, err)
}
current, err := common.ReadCurrencyWarsSnapshot(cfg.PlayerUID, c.ClientVersion, c.Provenance.GameAssemblySHA256)
if err != nil || current.Revision != 1 || string(current.WireData) != string(wire) {
t.Fatal("API changed active run instead of queuing")
}
}
+16 -2
View File
@@ -11,7 +11,7 @@ import (
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."
usage := "Usage: /faketime [status | off | window <begin Unix seconds> <end Unix seconds> | <Unix seconds> | <RFC3339 date with timezone>]. Window mode wraps the game clock at the end; off restores real time. 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]))
@@ -21,6 +21,20 @@ func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
case "off", "reset", "real":
now, err = common.SetGameTime(nil)
default:
if len(parts) > 1 && strings.EqualFold(parts[1], "window") {
if len(parts) != 4 {
err = fmt.Errorf("window requires begin and end Unix seconds")
} else {
begin, e1 := strconv.ParseInt(parts[2], 10, 64)
end, e2 := strconv.ParseInt(parts[3], 10, 64)
if e1 != nil || e2 != nil || begin < 0 || end > 253402300799 || end <= begin {
err = fmt.Errorf("invalid window Unix seconds")
} else {
now, err = common.SetGameTimeWindow(time.Unix(begin, 0), time.Unix(end, 0))
}
}
break
}
var target time.Time
if seconds, e := strconv.ParseInt(arg, 10, 64); e == nil {
if seconds < 0 || seconds > 253402300799 {
@@ -44,5 +58,5 @@ func apiFakeTimeCommand(w http.ResponseWriter, cmd string) {
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)})
writeJSON(w, map[string]any{"success": true, "command": cmd, "game_time": now.UTC().Format(time.RFC3339), "unix_seconds": now.Unix(), "offset_ms": state.OffsetMS, "window_begin_ms": state.WindowBeginMS, "window_end_ms": state.WindowEndMS, "output": fmt.Sprintf("Game time: %s (offset %d ms, window %d..%d ms). %s", now.UTC().Format(time.RFC3339), state.OffsetMS, state.WindowBeginMS, state.WindowEndMS, usage)})
}
+46
View File
@@ -0,0 +1,46 @@
package sdkserver
import (
"encoding/json"
"net/http/httptest"
"path/filepath"
"testing"
"gac-hkrpg-go/internal/common"
)
func TestFakeTimeWindowCommand(t *testing.T) {
chdirRepoRootSDK(t)
t.Setenv("GAC_CONFIG", "server.json")
t.Setenv("GAC_GAME_CLOCK_STATE_FILE", filepath.Join(t.TempDir(), "clock.json"))
call := func(command string) map[string]any {
t.Helper()
w := httptest.NewRecorder()
apiFakeTimeCommand(w, command)
var response map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
t.Fatal(err)
}
return response
}
if result := call("/faketime window 1000 2000"); result["success"] != true || result["unix_seconds"] != float64(1500) {
t.Fatalf("window command: %v", result)
}
before, err := common.ReadGameClock()
if err != nil {
t.Fatal(err)
}
if result := call("/faketime window 2000 1000"); result["success"] != false {
t.Fatalf("invalid window accepted: %v", result)
}
after, err := common.ReadGameClock()
if err != nil || before != after {
t.Fatalf("invalid window changed clock: %+v %v", after, err)
}
if result := call("/faketime off"); result["success"] != true {
t.Fatalf("reset: %v", result)
}
if state, err := common.ReadGameClock(); err != nil || state.WindowEndMS != 0 {
t.Fatalf("reset did not remove window: %+v %v", state, err)
}
}
@@ -0,0 +1,79 @@
package sdkserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestMaxCommandPreservesUnknownData(t *testing.T) {
chdirRepoRootSDK(t)
raw, err := os.ReadFile("freesr-data.json")
if err != nil {
t.Fatal(err)
}
var root map[string]json.RawMessage
if err = json.Unmarshal(raw, &root); err != nil {
t.Fatal(err)
}
root["future_root"] = json.RawMessage(`{"keep":true}`)
var avatars map[string]json.RawMessage
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
t.Fatal(err)
}
var avatar map[string]json.RawMessage
if err = json.Unmarshal(avatars["1204"], &avatar); err != nil {
t.Fatal(err)
}
avatar["future_avatar"] = json.RawMessage(`"preserved"`)
avatars["1204"], _ = json.Marshal(avatar)
root["avatars"], _ = json.Marshal(avatars)
raw, _ = json.Marshal(root)
if err = os.WriteFile("freesr-data.json", raw, 0600); err != nil {
t.Fatal(err)
}
beforePersistent, err := os.ReadFile("persistent")
if err != nil {
t.Fatal(err)
}
w := httptest.NewRecorder()
apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(`{"command":"/max"}`)))
var result struct {
Success bool `json:"success"`
}
if err = json.Unmarshal(w.Body.Bytes(), &result); err != nil || !result.Success {
t.Fatalf("command failed: %s (%v)", w.Body.String(), err)
}
raw, _ = os.ReadFile("freesr-data.json")
if err = json.Unmarshal(raw, &root); err != nil {
t.Fatal(err)
}
var futureRoot struct {
Keep bool `json:"keep"`
}
if err = json.Unmarshal(root["future_root"], &futureRoot); err != nil || !futureRoot.Keep {
t.Fatal("root data lost")
}
if err = json.Unmarshal(root["avatars"], &avatars); err != nil {
t.Fatal(err)
}
if err = json.Unmarshal(avatars["1204"], &avatar); err != nil {
t.Fatal(err)
}
var data struct {
Rank int `json:"rank"`
}
if err = json.Unmarshal(avatar["data"], &data); err != nil {
t.Fatal(err)
}
if string(avatar["future_avatar"]) != `"preserved"` || string(avatar["level"]) != "80" || string(avatar["promotion"]) != "6" || data.Rank != 6 {
t.Fatal("avatar fields lost or not updated")
}
afterPersistent, err := os.ReadFile("persistent")
if err != nil || string(beforePersistent) != string(afterPersistent) {
t.Fatal("persistent data was rewritten")
}
}
@@ -0,0 +1,68 @@
package sdkserver
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
func TestSceneCommandsPreserveImportedSave(t *testing.T) {
chdirRepoRootSDK(t)
before, err := os.ReadFile("freesr-data.json")
if err != nil {
t.Fatal(err)
}
run := func(command string) bool {
body, _ := json.Marshal(map[string]string{"command": command})
w := httptest.NewRecorder()
apiCommand(w, httptest.NewRequest(http.MethodPost, "/api/command", strings.NewReader(string(body))))
var result struct {
Success bool `json:"success"`
}
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
t.Fatal(err)
}
return result.Success
}
persistentBefore, err := os.ReadFile("persistent")
if err != nil {
t.Fatal(err)
}
for _, invalid := range []string{"/teleport invalid", "/scene 0", "/teleport 123 bad", "/teleport 123 456 extra", "/clear lineup"} {
if run(invalid) {
t.Fatalf("accepted %s", invalid)
}
after, _ := os.ReadFile("persistent")
if string(after) != string(persistentBefore) {
t.Fatalf("invalid command %s wrote persistent", invalid)
}
}
for _, command := range []string{"/teleport 123 456"} {
if !run(command) {
t.Fatalf("command failed: %s", command)
}
after, err := os.ReadFile("freesr-data.json")
if err != nil || string(after) != string(before) {
t.Fatalf("%s changed imported save", command)
}
}
raw, err := os.ReadFile("persistent")
if err != nil {
t.Fatal(err)
}
var saved struct {
Scene struct {
PlaneID uint32 `json:"plane_id"`
FloorID uint32 `json:"floor_id"`
} `json:"scene"`
}
if err := json.Unmarshal(raw, &saved); err != nil {
t.Fatal(err)
}
if saved.Scene.PlaneID != 123 || saved.Scene.FloorID != 456 {
t.Fatalf("persistent changes missing: %+v", saved)
}
}
+22
View File
@@ -0,0 +1,22 @@
package sdkserver
import (
"encoding/json"
"net/http/httptest"
"testing"
)
func TestBeforeVerifyReturnsDataObject(t *testing.T) {
w := httptest.NewRecorder()
beforeVerifyReply(w)
var body struct {
Retcode int `json:"retcode"`
Data map[string]any `json:"data"`
}
if err := json.Unmarshal(w.Body.Bytes(), &body); err != nil {
t.Fatal(err)
}
if w.Code != 200 || body.Retcode != 0 || body.Data == nil {
t.Fatalf("beforeVerify response cannot initialize SDK data: %s", w.Body.String())
}
}
+20
View File
@@ -0,0 +1,20 @@
package sdkserver
import (
"os"
"testing"
)
func TestLoadHotfixIgnoresEmptyCacheWhenBundledEntryExists(t *testing.T) {
t.Chdir(t.TempDir())
if err := os.WriteFile("hotfix.json", []byte(`{"bundled":{"asset_bundle_url":"bundle"},"override":{"asset_bundle_url":"old"}}`), 0600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile("versions.json", []byte(`{"bundled":{},"override":{"asset_bundle_url":"user"},"cached":{"asset_bundle_url":"cached"}}`), 0600); err != nil {
t.Fatal(err)
}
got := loadHotfix()
if got["bundled"].AssetBundleURL != "bundle" || got["override"].AssetBundleURL != "user" || got["cached"].AssetBundleURL != "cached" {
t.Fatalf("incorrect hotfix cache merge: %+v", got)
}
}
+24 -1
View File
@@ -42,8 +42,21 @@ func loadHotfix() map[string]VersionConfig {
if raw, err := os.ReadFile("hotfix.json"); err == nil {
_ = json.Unmarshal(raw, &out)
}
if out == nil {
out = map[string]VersionConfig{}
}
if raw, err := os.ReadFile("versions.json"); err == nil {
_ = json.Unmarshal(raw, &out)
var cached map[string]VersionConfig
if json.Unmarshal(raw, &cached) == nil {
for version, cfg := range cached {
// A failed fetch previously persisted an empty cache entry. It
// must not hide usable bundled metadata for the same version.
if cfg == (VersionConfig{}) && out[version] != (VersionConfig{}) {
continue
}
out[version] = cfg
}
}
}
return out
}
@@ -201,6 +214,7 @@ func queryGateway(state *appState) http.HandlerFunc {
_ = params{}
cfg := state.getOrInsertHotfix(version, seed)
Log(fmt.Sprintf("[gateway] version=%q game=%s:%d asset=%t ex_resource=%t lua=%t", version, state.runtime.PublicGameHost, state.runtime.GamePort, cfg.AssetBundleURL != "", cfg.ExResourceURL != "", cfg.LuaURL != ""))
if state.runtime.LuaOverlay.Enabled {
cfg.LuaURL = state.runtime.PublicSDKURL + luaOverlayRoute
}
@@ -305,6 +319,12 @@ func (a *appState) granterLoginReply(w http.ResponseWriter) {
})
}
// The Android SDK decodes data as BeforeVerifyEntity before continuing
// into the game. An empty object supplies its default false flags.
func beforeVerifyReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{"data": map[string]any{}, "message": "OK", "retcode": 0})
}
func riskyCheckReply(w http.ResponseWriter) {
writeJSON(w, map[string]any{
"data": map[string]any{
@@ -628,6 +648,7 @@ func Listen() error {
mux.HandleFunc("/{product_name}/combo/granter/api/getConfig", func(w http.ResponseWriter, _ *http.Request) { comboGetConfigReply(w) })
mux.HandleFunc("/{product_name}/combo/granter/api/compareProtocolVersion", func(w http.ResponseWriter, _ *http.Request) { compareProtocolVersionReply(w) })
mux.HandleFunc("/{product_name}/combo/granter/login/v2/login", func(w http.ResponseWriter, _ *http.Request) { state.granterLoginReply(w) })
mux.HandleFunc("/{product_name}/combo/granter/login/beforeVerify", func(w http.ResponseWriter, _ *http.Request) { beforeVerifyReply(w) })
mux.HandleFunc("/{product_name}/mdk/agreement/api/getAgreementInfos", func(w http.ResponseWriter, _ *http.Request) { agreementInfosReply(w) })
mux.HandleFunc("/combo/box/api/config/sdk/combo", func(w http.ResponseWriter, _ *http.Request) { comboConfigReply(w) })
@@ -664,6 +685,8 @@ func Listen() error {
state.loginReply(w, r)
case strings.Contains(path, "combo/granter/login/v2/login"):
state.granterLoginReply(w)
case strings.Contains(path, "combo/granter/login/beforeVerify"):
beforeVerifyReply(w)
case strings.Contains(path, "mdk/shield/api/loadConfig"):
shieldLoadConfigReply(w)
case strings.Contains(path, "combo/granter/api/getConfig"):
+4
View File
@@ -213,6 +213,10 @@ func (k *KCP) State() int32 { return k.state }
// IsDeadLink reports whether resends exceeded the dead link limit.
func (k *KCP) IsDeadLink() bool { return k.state != 0 }
// PendingSends reports segments waiting to be transmitted or acknowledged.
// Callers must serialize access with other KCP operations.
func (k *KCP) PendingSends() int { return len(k.sndQueue) + len(k.sndBuf) }
func (k *KCP) moveBuf() {
for len(k.rcvBuf) > 0 {
seg := &k.rcvBuf[0]
+1 -1
View File
@@ -26,7 +26,7 @@
"asset_verification_note": "2026-10-24 Player.log: all six captured banner prefab paths were checked via RPG.Client.GlobalVars.s_AssetMgr.Exists without loading. Standard and Novice exist; all four captured limited character/lightcone prefabs (UI/Drawcard/GachaPanelLimited/*) are absent from the installed asset manifest. The captured Seele prefab UI/Drawcard/GachaPanelLimited/AvatarGacha_1102.prefab previously caused UIPrefabLoader ArgumentNullException inside GachaMainPage._create_pools_panel when sent. Do not enable a prefab solely because it exists in the flat table.",
"mapped_banner_preview_count": 0,
"native_presentation": {
"enabled": false,
"enabled": true,
"template_gacha_id": 1001,
"source": "Current initialized Standard-pool native cloning and GetInfo pre-bind restoration; user confirmed one-banner native frame, splash art and horizontal thumbnail on 2026-10-25. Five-star mapped tabs only, with three matching sandbox four-star cards; full multi-tab draw/exit still awaiting Client confirmation."
},
+111 -1
View File
@@ -11,7 +11,9 @@
"ExcelOutput/RelicMainAffixConfig.json",
"ExcelOutput/RelicSubAffixConfig.json"
],
"note": "Material, Usable and Mission item IDs; all released Light Cones; and visible BASIC Relic templates joined from RelicConfig and ItemConfigRelic. MainAffixID is selected from the matching MainAffixGroup; sub-affixes come from the matching SubAffixGroup and exclude the main property. Display, Virtual and Pet records are excluded."
"note": "Material, Usable and Mission item IDs; all released Light Cones; and visible BASIC Relic templates joined from RelicConfig and ItemConfigRelic. MainAffixID is selected from the matching MainAffixGroup; sub-affixes come from the matching SubAffixGroup and exclude the main property. Display, Virtual and Pet records are excluded.",
"supplementary_relic_capture": "resources/RelicClientCapture.json",
"supplementary_note": "Four BASIC RelicConfig rows and affix groups were read from Client; ItemConfigRelic visibility was not captured."
},
"item_ids": [
101,
@@ -24045,6 +24047,114 @@
"step": 0
}
]
},
{
"relic_id": 63295,
"relic_set_id": 329,
"main_affix_id": 1,
"sub_affixes": [
{
"sub_affix_id": 1,
"count": 1,
"step": 0
},
{
"sub_affix_id": 2,
"count": 1,
"step": 0
},
{
"sub_affix_id": 3,
"count": 1,
"step": 0
},
{
"sub_affix_id": 5,
"count": 1,
"step": 0
}
]
},
{
"relic_id": 63296,
"relic_set_id": 329,
"main_affix_id": 1,
"sub_affixes": [
{
"sub_affix_id": 1,
"count": 1,
"step": 0
},
{
"sub_affix_id": 2,
"count": 1,
"step": 0
},
{
"sub_affix_id": 3,
"count": 1,
"step": 0
},
{
"sub_affix_id": 4,
"count": 1,
"step": 0
}
]
},
{
"relic_id": 63305,
"relic_set_id": 330,
"main_affix_id": 1,
"sub_affixes": [
{
"sub_affix_id": 1,
"count": 1,
"step": 0
},
{
"sub_affix_id": 2,
"count": 1,
"step": 0
},
{
"sub_affix_id": 3,
"count": 1,
"step": 0
},
{
"sub_affix_id": 5,
"count": 1,
"step": 0
}
]
},
{
"relic_id": 63306,
"relic_set_id": 330,
"main_affix_id": 1,
"sub_affixes": [
{
"sub_affix_id": 1,
"count": 1,
"step": 0
},
{
"sub_affix_id": 2,
"count": 1,
"step": 0
},
{
"sub_affix_id": 3,
"count": 1,
"step": 0
},
{
"sub_affix_id": 4,
"count": 1,
"step": 0
}
]
}
]
}
+273
View File
@@ -0,0 +1,273 @@
{
"source": {
"kind": "client table capture",
"client_version": "4.6.54",
"game_assembly_sha256": "4aa08a0706f27d2cf9951b61920c179140ece9ea94f7e34422c18e46922939b6",
"captured_at": "2026-11-02",
"tables": [
"RelicConfigExcelTable",
"RelicMainAffixConfigExcelTable",
"RelicSubAffixConfigExcelTable"
],
"scope": "Four relic IDs and main affix groups 55/56 and sub-affix group 5."
},
"relics": [
{
"Rarity": 5,
"Mode": 1,
"ID": 63295,
"MainAffixGroup": 55,
"MaxLevel": 15,
"SubAffixGroup": 5,
"SetID": 329,
"ExpProvide": 1500,
"ExpType": 4,
"Type": 5,
"CoinCost": 2250
},
{
"Rarity": 5,
"Mode": 1,
"ID": 63296,
"MainAffixGroup": 56,
"MaxLevel": 15,
"SubAffixGroup": 5,
"SetID": 329,
"ExpProvide": 1500,
"ExpType": 4,
"Type": 6,
"CoinCost": 2250
},
{
"Rarity": 5,
"Mode": 1,
"ID": 63305,
"MainAffixGroup": 55,
"MaxLevel": 15,
"SubAffixGroup": 5,
"SetID": 330,
"ExpProvide": 1500,
"ExpType": 4,
"Type": 5,
"CoinCost": 2250
},
{
"Rarity": 5,
"Mode": 1,
"ID": 63306,
"MainAffixGroup": 56,
"MaxLevel": 15,
"SubAffixGroup": 5,
"SetID": 330,
"ExpProvide": 1500,
"ExpType": 4,
"Type": 6,
"CoinCost": 2250
}
],
"main_affixes": [
{
"GroupID": 55,
"AffixID": 1,
"Property": 32,
"LevelAdd": "0.0241920005",
"BaseValue": "0.0691199996"
},
{
"GroupID": 55,
"AffixID": 2,
"Property": 33,
"LevelAdd": "0.0241920005",
"BaseValue": "0.0691199996"
},
{
"GroupID": 55,
"AffixID": 3,
"Property": 34,
"LevelAdd": "0.0302400004",
"BaseValue": "0.0863999999"
},
{
"GroupID": 55,
"AffixID": 4,
"Property": 12,
"LevelAdd": "0.0217730009",
"BaseValue": "0.0622080001"
},
{
"GroupID": 55,
"AffixID": 5,
"Property": 14,
"LevelAdd": "0.0217730009",
"BaseValue": "0.0622080001"
},
{
"GroupID": 55,
"AffixID": 6,
"Property": 16,
"LevelAdd": "0.0217730009",
"BaseValue": "0.0622080001"
},
{
"GroupID": 55,
"AffixID": 7,
"Property": 18,
"LevelAdd": "0.0217730009",
"BaseValue": "0.0622080001"
},
{
"GroupID": 55,
"AffixID": 8,
"Property": 20,
"LevelAdd": "0.0217730009",
"BaseValue": "0.0622080001"
},
{
"GroupID": 55,
"AffixID": 9,
"Property": 22,
"LevelAdd": "0.0217730009",
"BaseValue": "0.0622080001"
},
{
"GroupID": 55,
"AffixID": 10,
"Property": 24,
"LevelAdd": "0.0217730009",
"BaseValue": "0.0622080001"
},
{
"GroupID": 56,
"AffixID": 1,
"Property": 59,
"LevelAdd": "0.0362880004",
"BaseValue": "0.1036800008"
},
{
"GroupID": 56,
"AffixID": 2,
"Property": 54,
"LevelAdd": "0.0108860011",
"BaseValue": "0.0311040000"
},
{
"GroupID": 56,
"AffixID": 3,
"Property": 32,
"LevelAdd": "0.0241920005",
"BaseValue": "0.0691199996"
},
{
"GroupID": 56,
"AffixID": 4,
"Property": 33,
"LevelAdd": "0.0241920005",
"BaseValue": "0.0691199996"
},
{
"GroupID": 56,
"AffixID": 5,
"Property": 34,
"LevelAdd": "0.0302400004",
"BaseValue": "0.0863999999"
}
],
"sub_affixes": [
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "33.870039001",
"StepValue": "4.2337550008",
"Property": 27,
"AffixID": 1
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "16.935019000",
"StepValue": "2.1168770007",
"Property": 29,
"AffixID": 2
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "16.935019000",
"StepValue": "2.1168770007",
"Property": 31,
"AffixID": 3
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0345600005",
"StepValue": "0.0043200000",
"Property": 32,
"AffixID": 4
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0345600005",
"StepValue": "0.0043200000",
"Property": 33,
"AffixID": 5
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0431999999",
"StepValue": "0.0053999999",
"Property": 34,
"AffixID": 6
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "2",
"StepValue": "0.3000000002",
"Property": 51,
"AffixID": 7
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0259200003",
"StepValue": "0.0032400002",
"Property": 52,
"AffixID": 8
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0518400007",
"StepValue": "0.0064800004",
"Property": 53,
"AffixID": 9
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0345600005",
"StepValue": "0.0043200000",
"Property": 56,
"AffixID": 10
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0345600005",
"StepValue": "0.0043200000",
"Property": 57,
"AffixID": 11
},
{
"StepNum": 2,
"GroupID": 5,
"BaseValue": "0.0518400007",
"StepValue": "0.0064800004",
"Property": 59,
"AffixID": 12
}
]
}
+21
View File
@@ -0,0 +1,21 @@
-- Read only affix groups explicitly referenced by the four captured relics.
do
local groups={{kind='main',group=55,max=10},{kind='main',group=56,max=5},{kind='sub',group=5,max=12}}
local s=_G.__gacRelicAffix20261102 or {group=1,id=1}; _G.__gacRelicAffix20261102=s
if s.group>#groups then return end
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
for i=1,3 do
local g=groups[s.group]; if not g then return end
local id=s.id
local ok,err=pcall(function()
local tableObj=g.kind=='main' and CS.RPG.GameCore.RelicMainAffixConfigExcelTable or CS.RPG.GameCore.RelicSubAffixConfigExcelTable
local row=tableObj.GetData(g.group,id)
CS.UnityEngine.Debug.Log('[GAC_RELIC_AFFIX] '..g.kind..' '..g.group..' '..id..' '..CS.Newtonsoft.Json.JsonConvert.SerializeObject(row))
end)
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_AFFIX] ERROR '..g.kind..' '..g.group..' '..id..' '..tostring(err)) end
s.id=s.id+1; if s.id>g.max then s.id=1;s.group=s.group+1 end
end
end
+16
View File
@@ -0,0 +1,16 @@
-- Capture only four ItemConfigRelic rows, one per heartbeat, outside battle.
do
local ids={63295,63296,63305,63306}; local s=_G.__gacRelicItem20261102 or {index=1}; _G.__gacRelicItem20261102=s
if s.index>#ids then return end
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
local id=ids[s.index]
local ok,err=pcall(function()
local row=CS.RPG.GameCore.ItemConfigRelicExcelTable.GetData(id)
CS.UnityEngine.Debug.Log('[GAC_RELIC_ITEM] '..id..' '..CS.Newtonsoft.Json.JsonConvert.SerializeObject(row))
end)
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_ITEM] ERROR '..id..' '..tostring(err)) end
s.index=s.index+1
end
+17
View File
@@ -0,0 +1,17 @@
-- Targeted capture of four identified 4.6 planar pieces, one row per heartbeat.
do
local ids={63295,63296,63305,63306}
local s=_G.__gacRelicRows20261102 or {index=1}; _G.__gacRelicRows20261102=s
if s.index>#ids then return end
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
local id=ids[s.index]
local ok,err=pcall(function()
local row=CS.RPG.GameCore.RelicConfigExcelTable.GetData(id)
CS.UnityEngine.Debug.Log('[GAC_RELIC_ROW] '..id..' '..CS.Newtonsoft.Json.JsonConvert.SerializeObject(row))
end)
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_ROW] ERROR '..id..' '..tostring(err)) end
s.index=s.index+1
end
+22
View File
@@ -0,0 +1,22 @@
-- Temporary targeted metadata probe. One named type per heartbeat, outside battle.
do
local names={'RelicConfigExcelTable','RelicMainAffixConfigExcelTable','RelicSubAffixConfigExcelTable','ItemConfigRelicExcelTable','EquipmentConfigExcelTable'}
local s=_G.__gacRelicSchema20261102 or {index=1}; _G.__gacRelicSchema20261102=s
if s.index>#names then return end
local flags=CS.System.Enum.ToObject(typeof(CS.System.Reflection.BindingFlags),60)
local manager=CS.RPG.Client.GlobalVars.s_GamePhaseManager
local phase=manager and typeof(CS.RPG.Client.GamePhaseManager):GetField('_CurrentPhase',flags):GetValue(manager)
if not phase or typeof(CS.RPG.Client.BattleGamePhase):IsInstanceOfType(phase) then return end
local name=names[s.index]
local ok,err=pcall(function()
local a=typeof(CS.RPG.GameCore.AvatarExcelTable).Assembly
local t=a:GetType('RPG.GameCore.'..name)
CS.UnityEngine.Debug.Log('[GAC_RELIC_SCHEMA] TYPE '..name..' '..tostring(t))
if t then
local methods=t:GetMethods(flags)
for i=0,math.min(methods.Length-1,39) do if methods[i].DeclaringType==t then CS.UnityEngine.Debug.Log('[GAC_RELIC_SCHEMA] METHOD '..tostring(methods[i])) end end
end
end)
if not ok then CS.UnityEngine.Debug.Log('[GAC_RELIC_SCHEMA] ERROR '..name..' '..tostring(err)) end
s.index=s.index+1
end