- Shadowverse, Steam (App ID 453480)
- Unity 2020.3.18 LTS, Mono build (Assembly-CSharp.dll decompiles directly)
- Root namespace:
Wizard - API framework namespace:
Cute(Cygames in-house) - Networking: BestHTTP (Socket.IO client bundled), MessagePack (neuecc), LitJson / MiniJSON, Sqlite3
- Memory tamper protection via CodeStage AntiCheat (ObscuredTypes)
Values from Cute.CustomPreference.InitFrameWorkSettings.
| Use | URL | Scheme |
|---|---|---|
| API (PHP) | utoongaize.shadowverse.jp/shadowverse/ |
https |
| Resource CDN | shadowverse.akamaized.net/ |
https |
| Node (battle) | starts empty, set at runtime from the match response node_server_url |
ws:// (wss:// optional) |
| DeckBuilder | shadowverse-portal.com/api/v1/game_api/ |
https |
- API and CDN are forced to HTTPS by
SetScemeMode(Https) - The Node URL starts empty. A match response
data.node_server_urlis passed toSetNodeServerURL - If OpenVerse returns its own Node address in the match response, the client connects there (no binary patch needed)
- AES-256-CBC, block 128
- key =
Cryptographer.generateKeyString()(random 32 bytes) - IV = first 16 bytes of
Certification.Udidwith dashes stripped - Layout:
[ciphertext][key(32 bytes plaintext)](key appended) - Decryption takes the last 32 bytes as the key
- AES-256-CBC / PKCS7, block 128
- key = random 32 bytes, IV = first 16 bytes of the key
- Layout:
[key(32 bytes plaintext)][base64(ciphertext)](key prepended)
- Body is
_createBodyMsgpack(default) or_createBodyJson - With
encrypt=trueit goes throughCryptAES.encrypt(= EncryptRJ256Api) - Request:
PostParams-> JSON -> MessagePack -> AES, sent as raw bytes - Response:
{ data_headers: { result_code, servertime }, data: {...} } - Read a response with
CryptAES.decryptthenMessagePackSerializer.ToJson. The body is base64 text - Success is
result_code == 1
- Event names
msg(normal) /hand(hand data) - Send:
JSON -> encryptForNode -> MessagePackSerializer.Serialize(string) - Receive:
Deserialize<string> -> decryptForNode -> MiniJSON - Each message carries a
urifield for the command type (InitNetwork / TurnStart / Resume / Watch / Maintenance ...) - A heartbeat called
Gungnirexists - Non-standard mix: the URL reports
EIO=4, but payload framing is Engine.IO v3 ([type][ascii length][0xFF]) and binary attachments are Socket.IO v2 ({_placeholder,num}plus a separate chunk with a leading0x04). Default transport is polling, upgrading to websocket. PingInterval/PingTimeout are fixed client-side at 2000/5000ms
PostParams:viewer_id,steam_id,steam_session_ticket- Steam session ticket auth
- A private server can stub this by skipping validation and just issuing a
viewer_id
Udid, ShortUdid, SessionId, Param, Device, AppVersion, ResVersion, DeviceId, DeviceName, GraphicsDeviceName, IpAddress, PlatformOsVersion, KeyChain, IDFA, Locale, Language, CountryCode, Platform, IsWSS, IsIpv6, DevAccessSecretKey, CardMasterHash
SetUp.InitFrameWorkSettings: sets URLs and schemes, callsNetworkManager.Certification()CheckSpecialTitleTask: first request (encrypt=true, useJson=false). AWizard.BaseTask, only needsdata_headers.result_codeandservertimeGameStartCheckTask(check/game_start): startup check. ACute.NetworkTask, needsdata.tos_state,policy_state,kor_authority_state,tos_id,policy_id,kor_authority_id- On to home
Startup / auth / payment: tool/signup, check/special_title, check/game_start, account/get_by_social_account, account/chain_by_transition_code, payment/*, payment_pc/*
Main game APIs are Wizard.BaseTask derivatives, split by type below.
Format wire code (deck_format below) is 1=Rotation, 2=Unlimited, 3=PreRotation, 4=Crossover, 5=MyRotation, 10=TwoPick, 20=Sealed, 31=Hof, 33=Windfall, 39=Avatar, 0=All, a separate system from the internal enum (converted by FormatConvertApi).
| Path | Request | Response main fields |
|---|---|---|
deck/info |
deck_format |
Format.All: data.user_deck_rotation / _unlimited / _pre_rotation / _crossover / _my_rotation / _avatar (all guarded). Single format: data.user_deck_list, and data.maintenance_card_list is always required unguarded |
deck/update |
deck_no, class_id, leader_skin_id, is_random_leader_skin, leader_skin_id_list, sleeve_id, deck_name, is_delete(0/1), card_id_array, deck_format, rotation_id (Crossover adds sub_class_id) |
updated user_deck_* group + data.achieved_info:{} + data.reward_list:[] (both unguarded) |
deck/get_empty_deck_number |
deck_format |
data.empty_deck_num:int (0 or less = no slot) |
deck/update_name |
deck_no, deck_name, deck_format |
data.user_deck (one) |
deck/update_sleeve |
deck_no, sleeve_id, deck_format |
data.user_deck |
deck/update_leader_skin |
deck_no, leader_skin_id, deck_format |
data.user_deck |
deck/update_order |
deck_order:int[], deck_format |
updated group |
deck/delete_deck_list |
deck_no_list:int[], deck_format |
updated group |
auto_deck/create |
deck_format, class_id, chosen_card_ids, tournament_id, rotation_id |
data:[int...] (flat card id array) |
Required unguarded keys are deck_name (string), class_id (int 1-8), and card_id_array (int[]). The rest are guarded.
Deck-building rules are handled client-side. The server only returns restricted_card_exists and maintenance_card_list, and since OpenVerse unlocks every card, no ownership check is used.
Deck sharing (portal's deck_code / deck) is a separate host, self-hosted in OpenVerse.
CP battle (practice) and deck introduction. The battle and AI are client-side, so the server only handles setup and result recording.
| Path | Request | Response main fields |
|---|---|---|
practice/info |
(none) | data:[{practice_id, text_id, class_id, chara_id, degree_id, ai_deck_level, ai_logic_level, ai_max_life, is_campaign_practice, battle3dfield_id, is_maintenance}...] (plain array, PracticeDataMgr iterates) |
practice/deck_list |
deck_format (Format.All) |
same shape as deck/info (parsed by ParseDeckInfoResponceData) |
practice/start |
practice_id etc. |
data:{} (reads mission_parameter if present) |
practice/finish |
deck_no, is_win, evolve_count, total_turn, enemy_class_id, difficulty, deck_format, class_id, mission, recovery_data |
data:{get_class_experience, class_experience, class_level, achieved_info, reward_list} (achieved_info may be empty {}) |
introduce_deck/info |
series_id (-1 = latest) |
data:{series_id, display_format, display_deck_list:[deck+player_name+introduction+thumbnail_card_id], series_list:[{series_id, series_name, is_ts_rotation}]} |
introduce_deck/series_list |
(none) | data:{series_list:[...]} |
- AI decks and logic load client-side from the
master_practice_ai_settingbundle byai_deck_level/ai_logic_level, so the official ones are used - Starting a battle needs
open_battle_field_id_list(an array of unlocked background ids) in load/index, orBattleManagerBase.CalculationRandomStageNREs
| Path | Use | Main response |
|---|---|---|
open_room/create_room |
owner creates | data.room_id (5-digit numeric string), data.node_server_url (scheme stripped), data.battle_id, data.is_invitation_user:bool (all unguarded) |
open_room/enter_room |
visitor enters | data.result_reason:int (0=success), data.oppo_info.{oppoId,battlePoint,degreeId,emblemId,country_code,rank,max_rank,userName,isOfficial}, data.node_server_url, data.is_friend:int, data.guild_id/oppo_guild_id (all unguarded) |
open_room/leave_room |
visitor leaves | data.result_reason, data.room_result:int(1=OK) |
open_room/close_room |
owner closes | base.Parse only (result_code=1 is enough) |
open_room/force_release_room |
owner force-closes | data.room_result:int(1=OK), client retries with 10s backoff |
open_room/initialize_room_battle |
bookkeeping after socket connect, before the battle | data.battle_id, data.my_battle_result:{}, data.opponent_battle_result:{}, data.used_deck:int, data.is_settled:int |
open_room_battle/set_deck |
in-room deck select | not parsed (result_code=1 is enough), variants like gathering_room_battle/set_deck |
deck/deck_entry |
multi-deck select (Bo3 etc.) | not parsed |
open_room/ban_deck |
deck ban phase | not parsed |
Notes:
room_idis a server-assigned 5-digit numeric stringnode_server_urlis a scheme-less host:port (127.0.0.1:3001etc.), the client prependsws://- The entry notification arrives as a Socket
RoomEntrypush, not HTTP polling - Socket ACK wait timeout is 10s
| Path | Use |
|---|---|
battle/do_matching |
ranked match |
open_room_battle/do_matching |
room match |
Response is data.matching_state:int (3004=SUCCEEDED, 3007=SUCCEEDED_OWNER, 3011=SUCCEEDED_AI), data.timeout_period:int, data.retry_period:int, data.battle_id:string, data.node_server_url:string, data.card_master_id:int (on success), and the client polls until it connects.
Connect to: ws://<node_server_url>/?EIO=4&transport=websocket
On HTTP headers, not the URL query.
BattleId: raw stringviewerId:CryptAES.encryptForNode(viewerId.ToString()), first 32 chars are the key, the rest base64 ciphertextUser-Agent:SystemInfo.operatingSystem
msg: normal event, payload =MessagePack(CryptAES.encryptForNode(JSON(...))), server must ACK with an intpubSeqhand: hand actions (touch / slide / select_skill). payload =MessagePack(JSON(...))(no AES)synchronize: server push only, all uris ride here, so do not name theeventafter theurialive: heartbeat (Gungnir), starts after InitNetwork succeeds
uri: command typeviewerId:intuuid:string(Certification.Udid)bid:string(battle_id assigned after matching)pubSeq:int(client->server counter, monotonic from 1)playSeq:int(server->client counter, server-assigned)cat:int(EmitCategory:1=battle, 2=matching, 3=room, 11=watch, 99=general)
- Client->
InitNetwork(cat:99) -> Server->InitNetworkecho (payload optional, passes once_initNetworkSuccessis set) - Client->
RoomCreate(owner) orRoomEntry(visitor) (cat:3) -> Server ACK withresultCode:1(ReceiveNodeResultCode.Success) - Once both pick decks and
RoomReady, Server->Matched:{uri, bid, turnState:0|1, selfInfo, oppoInfo, selfDeck:[{idx,cardId}...], playSeq:1} - Both->
Loaded(cat:1, pubSeq:2) -> Server->BattleStart(playSeq:2):{uri, battleStartDate:<unix-microseconds>, selfInfo, oppoInfo} - Server->
Deal(playSeq:3):{uri, cards:[{idx,cardId,isSelf,RedrawCardPosition}...]}-> Client->Swap-> Server->Ready
Missing any of these crashes the client.
selfInfo:rank, classId, charaId, viewerId, userName, fieldId, seed, deckCountoppoInfo: same as selfInfo + optionalisOfficialselfDeck:idx, cardId
The BattleId and viewerId (needs decrypt) headers on WS upgrade, plus each msg payload's viewerId and uuid, are cross-checked. The server pairs the opponent's viewerId and puts it in Matched.oppoInfo.viewerId.
The Wizard.RoomMatch.RoomUri enum value rides as-is. For now only RoomCreate / RoomEntry / GatheringEntry / Leave / Release / ForceRelease / DeckSelect / DeckConfirm / TurnSelect / RoomReady / Rematch are used.
1= success204= version error,308= payment validation error2000-2999= maintenance- Node side:
30001/30213= return to title,30002= no contest
Room match flow (diagram: diagrams/battle-sequence.svg).
sequenceDiagram
participant C as Client (Steam)
participant A as API (utoongaize / OpenVerse)
participant N as Node battle server
C->>A: 1. check/game_start (AES + MsgPack)
A-->>C: 2. result_code:1, {data_headers, data}
C->>A: 3. RoomCreate
A-->>C: 4. room_id, node_server_url
Note over C,N: node_server_url decides the battle server (runtime injection)
C->>N: 5. Socket.IO connect (viewer_id)
N-->>C: 6. InitNetwork
loop turn progression
C->>N: 7. emit "msg" (uri=TurnStart, encryptForNode)
N-->>C: 8. board update (operation list)
end
Payload wrapping:
- API:
object -> JSON -> AES (key appended) -> HTTP body - Node:
object -> JSON -> AES (key prepended) -> MsgPack(string) -> socket.emit
- How
DevAccessSecretKey/CardMasterHashare generated and whether the server validates them - Details of all battle URIs after BattleStart (TurnStart / PlayActions / TurnEndActions / TurnEnd / Judge / BattleFinish etc.) and the operation list breakdown
- Where card effects resolve (server side or client side)
- Spec of the recovery endpoints (
open_room/get_recovery_params,battle/get_recovery_params)
