docs(tower): document Sky Corridor system status and remaining gaps

Most of the Tower/Tenrouirai system is already implemented on develop.
This doc captures what works, what is broken (PostTenrouirai Op=1 no-op,
block2 never written, PresentBox empty), and the remaining unknown packet
fields — so future contributors know exactly where to focus effort.

Also updates unimplemented.md to note the feature/tower branch was
superseded by direct integration into develop.
This commit is contained in:
Houmgaor
2026-03-22 20:35:56 +01:00
parent dca7152656
commit 842426e001
2 changed files with 512 additions and 1 deletions

511
docs/tower.md Normal file
View File

@@ -0,0 +1,511 @@
# Tower / Sky Corridor (天廊 / Tenrou)
Tracks what is known about the Tower system and what remains to be reverse-engineered
or implemented in Erupe.
The core of this system **is already implemented on `develop`** via the repository and
service pattern (`repo_tower.go`, `svc_tower.go`, `handlers_tower.go`). Two branches carry
earlier work: `wip/tower` predates the refactor and uses direct SQL; `feature/tower` merges
`wip/tower` with `feature/earth`. Their useful findings are incorporated below.
The `feature/tower` branch is **not mergeable in its current state** — it diverged too far
from `develop` and was superseded by the direct integration of tower code into `develop`.
Its main remaining value is the `PresentBox` handler logic and the `TimeTaken`/`CID`
field naming for `MsgMhfPostTowerInfo`.
---
## Game Context
The **Sky Corridor** (天廊, *Tenrou*) is a permanent dungeon introduced in MHFG6. Players
explore a multi-floor structure built by an ancient civilization on a desolate island. Unlike
quest zones, the Sky Corridor has its own progression systems independent of normal quests.
### Individual Tower Progression
- Each clear adds **10 minutes** to the timer; quests consist of 24 randomly selected floors.
- Each floor contains 23 rooms separated by gates, with Felynes that drop **purple medals**,
fixed-position treasure chests, and shiny crystals.
- Players accumulate **Tower Rank (TR)**, **Tower Rank Points (TRP)**, and
**Tower Skill Points (TSP)**.
- TSP is spent to level up one of 64 tower-specific skills stored server-side.
- Two floor-count columns exist in the DB (`block1`, `block2`), corresponding to the
pre-G7 and G7+ floor tiers respectively.
### Duremudira (The Guardian)
**Duremudira** (天廊の番人, Tower Guardian) is the Emperor Ice Dragon — an Elder Dragon
introduced in MHFG6. It appears as an optional boss in the second district of the Sky
Corridor. Slaying it yields **Red and Grey Liquids** used to craft Sky Corridor Gems and
Sigils.
**Arrogant Duremudira** is a harder variant; less information about it is available in
English sources.
### Gems (Sky Corridor Decorations)
Gems are collectibles (30 slots, organized as 6 tiers × 5 per tier) obtained from the Tower.
They slot into equipment to activate skills without consuming armor skill slots. Stored
server-side as a 30-element CSV in `tower.gems`.
### Tenrouirai (天廊威来) — Guild Mission System
A guild-parallel challenge system layered over the Sky Corridor. Each guild has a
**mission page** (1-based) containing 3 active missions. Guild members contribute scores
by completing tower runs; when cumulative scores meet all three mission goals, the page
advances (after a guild RP donation threshold is also met). There are 33 hardcoded
missions across 3 blocks.
**Mission types** (the `Mission` field in `TenrouiraiData`):
| Value | Objective |
|-------|-----------|
| 1 | Floors climbed |
| 2 | Antiques collected |
| 3 | Chests opened |
| 4 | Felynes (cats) saved |
| 5 | TRP acquisition |
| 6 | Monster slays |
### Relation to the Earth Event Cycle
The Tower phase is **week 3** of the three-week Earth event rotation (see `docs/conquest-war.md`).
The `GetWeeklySeibatuRankingReward` handler (Operation=5, IDs 260001 and 260003) already
handles Tower dure kill rewards and the 155-entry floor reward table. These do not need to be
reimplemented here.
---
## Database Schema
All tower tables are defined in `server/migrations/sql/0001_init.sql`.
### `tower` — per-character progression
```sql
CREATE TABLE public.tower (
char_id integer,
tr integer, -- Tower Rank
trp integer, -- Tower Rank Points
tsp integer, -- Tower Skill Points
block1 integer, -- Floor count, era 1 (pre-G7)
block2 integer, -- Floor count, era 2 (G7+)
skills text, -- CSV of 64 skill levels
gems text -- CSV of 30 gem quantities (6 tiers × 5)
);
```
`skills` and `gems` default to `EmptyTowerCSV(N)` — comma-separated zeros — when NULL.
Gems are encoded as `tier << 8 | (index_within_tier + 1)` in wire responses.
### Guild columns (`guilds` and `guild_characters`)
```sql
-- guilds
tower_mission_page integer DEFAULT 1 -- Current Tenrouirai mission page
tower_rp integer DEFAULT 0 -- Accumulated guild tower RP
-- guild_characters
tower_mission_1 integer -- Member's score for mission slot 1
tower_mission_2 integer -- Member's score for mission slot 2
tower_mission_3 integer -- Member's score for mission slot 3
```
---
## Packet Overview
Ten packets implement the Tower system. All live in `network/mhfpacket/`. None have
`Build()` implemented (all return `NOT IMPLEMENTED`).
### `MsgMhfGetTowerInfo` — Client → Server → Client
Fetches character tower data. The `InfoType` field selects what data to return.
**Request** (`msg_mhf_get_tower_info.go`):
```
AckHandle uint32
InfoType uint32 — 1=TR/TRP, 2=TSP+skills, 3=level(pre-G7), 4=history, 5=level(G7+)
Unk0 uint32 — unknown; never used by handler
Unk1 uint32 — unknown; never used by handler
```
**Response**: variable-length array of frames via `doAckEarthSucceed`.
| InfoType | Response per frame |
|----------|-------------------|
| 1 | `TR int32, TRP int32` |
| 2 | `TSP int32, Skills [64]int16` |
| 3, 5 | `Floors int32, Unk1 int32, Unk2 int32, Unk3 int32` — one frame per era (1 for G7, 2 for G8+) |
| 4 | `[5]int16 (history group 0), [5]int16 (history group 1)` |
**InfoTypes 3 and 5** use the same code path and both return `TowerInfoLevel` entries.
The distinction between them is not understood. The `wip/tower` branch treats them
identically.
**TowerInfoLevel Unk1/Unk2/Unk3**: three of the four level-entry fields are unknown.
They are hardcoded to `5` in `wip/tower` and `0` on `develop`. Whether they carry
max floor, session count, or display state is not known.
**InfoType 4 (history)**: returns two groups of 5 × int16. The `wip/tower` branch
hardcodes them as `{1, 2, 3, 4, 5}` / `{1, 2, 3, 4, 5}`. Their meaning (e.g. recent
clear times, floor high scores) is not reverse-engineered. On `develop` they return zeros.
**Current state on develop**: Implemented. Reads from `repo_tower.GetTowerData()`.
History data is zero-filled (semantics unknown). Level Unk1Unk3 are zero-filled.
---
### `MsgMhfPostTowerInfo` — Client → Server → Client
Submits updated tower progress after a quest.
**Request** (`msg_mhf_post_tower_info.go`):
```
AckHandle uint32
InfoType uint32 — 1 or 7 = progress update, 2 = skill purchase
Unk1 uint32 — unknown; logged in debug mode
Skill int32 — skill index to level up (InfoType=2 only)
TR int32 — new Tower Rank to set
TRP int32 — TRP earned (added to existing)
Cost int32 — TSP cost (InfoType=2) or TSP earned (InfoType=1,7)
Unk6 int32 — unknown; logged in debug mode
Unk7 int32 — unknown; logged in debug mode
Block1 int32 — floor count increment (InfoType=1,7)
Unk9 int64 — develop: reads as int64; wip/tower: reads as TimeTaken int32 + CID int32
```
**Field disambiguation — `Unk9` vs `TimeTaken + CID`**: the `wip/tower` branch splits the
final 8 bytes into `TimeTaken int32` (quest duration in seconds) and `CID int32`
(character ID). This interpretation appears more correct than a single int64 — the character
ID would make sense as a submission attribution field. `develop` keeps it as `Unk9 int64`
until confirmed. This should be verified with a packet capture.
**InfoType 7**: handled identically to InfoType 1. The difference between them is unknown
— it may relate to whether the run included Duremudira or was a normal floor clear.
**TSP rate note**: the handler comment in both branches says "This might give too much TSP?
No idea what the rate is supposed to be." The `Cost` field is used for both TSP earned
(on progress updates) and TSP spent (on skill purchases); the actual earn rate formula is
unknown.
**`block2` not written**: `UpdateProgress` only writes `block1`. The `block2` column (G7+
floor era) is never incremented by the handler. This is likely a bug — `block2` should be
written when the client sends a G7+ floor run.
**Current state on develop**: Implemented. Calls `towerRepo.UpdateSkills` (InfoType=2) and
`towerRepo.UpdateProgress` (InfoType=1,7). `Unk9`/`Unk1`/`Unk6`/`Unk7` are logged in
debug mode but not acted on.
---
### `MsgMhfGetTenrouirai` — Client → Server → Client
Fetches Tenrouirai (guild mission) data.
**Request** (`msg_mhf_get_tenrouirai.go`):
```
AckHandle uint32
Unk0 uint8 — unknown; never used
DataType uint8 — 1=mission defs, 2=rewards, 4=guild progress, 5=char scores, 6=guild RP
GuildID uint32
MissionIndex uint8 — which mission to query scores for (DataType=5 only; 1-3)
Unk4 uint8 — unknown; never used
```
**DataType=1 response**: 33 frames, one per mission definition:
```
[uint8] Block — 13
[uint8] Mission — type (16, see table above)
[uint16] Goal — score required
[uint16] Cost — RP cost to unlock/advance
[uint8] Skill16 — 6 skill requirement bytes (values: 80, 40, 40, 20, 40, 50)
```
**DataType=2 response (rewards)**: `TenrouiraiReward` struct is defined but never
populated. Returns an empty array. Response format:
```
[uint8] Index
[uint16] Item[0..4] — 5 item IDs
[uint8] Quantity[0..4] — 5 quantities
```
No captures of a populated reward response are known.
**DataType=4 response**: 1 frame:
```
[uint8] Page — current mission page (1-based)
[uint16] Mission1 — aggregated guild score for slot 1 (capped to goal)
[uint16] Mission2 — aggregated guild score for slot 2 (capped to goal)
[uint16] Mission3 — aggregated guild score for slot 3 (capped to goal)
```
**DataType=5 response**: N frames, one per guild member with a non-null score:
```
[int32] Score
[14 bytes] HunterName (null-padded)
```
**DataType=6 response**: 1 frame:
```
[uint8] Unk0 — always 0
[uint32] RP — guild's accumulated tower RP
[uint32] Unk2 — unknown; always 0
```
**`TenrouiraiKeyScore`** (`Unk0 uint8, Unk1 int32`): defined and included in the
`Tenrouirai` struct but never written into or sent. Likely related to an unimplemented
DataType (possibly 3). Purpose unknown.
**Current state on develop**: DataTypes 1, 4, 5, 6 implemented. DataType 2 (rewards)
returns empty. `Unk0`, `Unk4`, and `TenrouiraiKeyScore` are unresolved.
---
### `MsgMhfPostTenrouirai` — Client → Server → Client
Submits Tenrouirai results or donates guild RP.
**Request** (`msg_mhf_post_tenrouirai.go`):
```
AckHandle uint32
Unk0 uint8
Op uint8 — 1 = submit mission results, 2 = donate RP
GuildID uint32
Unk1 uint8 — unknown
Op=1 fields:
Floors uint16 — floors climbed this run
Antiques uint16 — antiques collected
Chests uint16 — chests opened
Cats uint16 — Felynes saved
TRP uint16 — TRP obtained
Slays uint16 — monsters slain
Op=2 fields:
DonatedRP uint16 — RP to donate
PreviousRP uint16 — prior RP total (from client; used for display only?)
Unk2_03 uint16 — unknown; 4 reserved fields
```
**Critical gap — Op=1 does nothing**: the handler logs the fields in debug mode and
returns a success ACK, but **does not write any data to the database**. Mission scores
(`guild_characters.tower_mission_1/2/3`) are never updated from quest results. This means
Tenrouirai missions can never actually advance via normal gameplay — the `SUM` aggregation
in DataType=4 will always return zero.
To fix: the handler needs to determine which of the three active missions the current run
contributes to (based on mission type and the run's stats), then write to the appropriate
`tower_mission_N` column.
**Op=2 (RP donation)**: implemented. Deducts RP from character save data, updates
`guilds.tower_rp`, and advances the mission page when the cumulative donation threshold
is met. `Unk0`, `Unk1`, and `Unk2_0-3` are parsed but unused.
**Current state on develop**: Op=2 fully implemented. Op=1 is a no-op.
---
### `MsgMhfGetGemInfo` — Client → Server → Client
Fetches gem inventory or gem acquisition history.
**Request** (`msg_mhf_get_gem_info.go`):
```
AckHandle uint32
QueryType uint32 — 1=gem inventory, 2=gem history
Unk1 uint32 — unknown
Unk2Unk6 int32 — unknown; 5 additional fields
```
**QueryType=1 response**: 30 frames (one per gem slot):
```
[uint16] Gem — encoded as (tier << 8) | (index_within_tier + 1)
[uint16] Quantity
```
**QueryType=2 response**: N frames (gem history):
```
[uint16] Gem
[uint16] Message — purpose unknown; likely a display string ID
[uint32] Timestamp — Unix timestamp
[14 bytes] Sender — null-padded character name
```
**Current state on develop**: QueryType=1 implemented via `towerRepo.GetGems()`.
QueryType=2 returns empty (the `GemHistory` slice is never populated). `Unk1``Unk6`
are parsed but unused; purpose unknown.
---
### `MsgMhfPostGemInfo` — Client → Server → Client
Adds or transfers gems.
**Request** (`msg_mhf_post_gem_info.go`):
```
AckHandle uint32
Op uint32 — 1=add gem, 2=transfer gem
Unk1 uint32 — unknown
Gem int32 — gem ID encoded as (tier << 8) | (index+1)
Quantity int32 — amount
CID int32 — target character ID (Op=2 likely uses this)
Message int32 — display message ID? purpose unknown
Unk6 int32 — unknown
```
**Op=1 (add gem)**: implemented. Decodes the gem index from the `Gem` field, increments
the quantity in the CSV, and saves. Note: the index computation `(pkt.Gem >> 8 * 5) + (pkt.Gem - pkt.Gem&0xFF00 - 1%5)` may have operator precedence issues — verify with captures.
**Op=2 (transfer gem)**: not implemented. Handler comment: *"no way im doing this for now"*.
The `CID` field likely identifies the recipient character. Format of the response (if any
acknowledgement is sent to the recipient) is unknown.
**Current state on develop**: Op=1 implemented via `towerService.AddGem()`. Op=2 stub.
`Unk1`, `Message`, `Unk6` purposes unknown.
---
### `MsgMhfPresentBox` — Client → Server → Client
Fetches or claims items from the Tower present box (a reward inbox for seibatsu/Tower
milestone awards). This packet's field names differ between `develop` and `wip/tower`:
**Request**`wip/tower` naming (more accurate than develop's all-`Unk*` version):
```
AckHandle uint32
Unk0 uint32
Operation uint32 — 1=open list, 2=claim item, 3=close
PresentCount uint32 — number of PresentType entries that follow
Unk3 uint32 — unknown
Unk4 uint32 — unknown
Unk5 uint32 — unknown
Unk6 uint32 — unknown
PresentType []uint32 — array of present type IDs (length = PresentCount)
```
On `develop`, `Operation` is `Unk1` and `PresentCount` is `Unk2` — the field is correctly
used to drive the `for` loop but the semantic name is lost.
**Response** for Op=1 and Op=2: N frames via `doAckEarthSucceed`, each:
```
[uint32] ItemClaimIndex — unique claim ID
[int32] PresentType — echoes the request PresentType
[int32] Unk2Unk7 — 6 unknown fields (always 0 in captures)
[int32] DistributionType — 7201=item, 7202=N-Points, 7203=guild contribution
[int32] ItemID
[int32] Amount
```
**Critical gap — no claim tracking**: `ItemClaimIndex` is a sequential ID that the client
uses for "claimed" state, but the server has no DB table or flag for it. Every call
returns the same hardcoded items, so a player can claim the same rewards repeatedly.
**Op=3**: returns an empty buffer (close/dismiss).
**Current state on develop**: handler returns an empty item list (`data` slice is nil).
The `wip/tower` branch has a working hardcoded handler (7 dummy items per `PresentType`)
with the correct response structure. `Unk0`, `Unk3``Unk6` purposes unknown.
**Note**: `wip/tower`'s `MsgMhfPresentBox.Parse()` still contains `fmt.Printf` debug
print statements that must be removed before any merge.
---
### `MsgMhfGetNotice` — Client → Server → Client
Purpose unknown. Likely fetches in-lobby Tower notices or announcements.
**Request** (`msg_mhf_get_notice.go`):
```
AckHandle uint32
Unk0 uint32
Unk1 uint32
Unk2 int32
```
**Current state**: Stub on `develop` — returns `{0, 0, 0, 0}`. Response format unknown.
---
### `MsgMhfPostNotice` — Client → Server → Client
Purpose unknown. Likely submits a read-receipt or acknowledgement for a Tower notice.
**Request** (`msg_mhf_post_notice.go`):
```
AckHandle uint32
Unk0 uint32
Unk1 uint32
Unk2 int32
Unk3 int32
```
**Current state**: Stub on `develop` — returns `{0, 0, 0, 0}`. Response format unknown.
---
## What Is Already Working on Develop
- Character tower data (TR, TRP, TSP, skills, floor counts) is read and written via the
full repository pattern.
- Skill levelling (InfoType=2) deducts TSP and increments the correct CSV index.
- Floor progress (InfoType=1,7) updates TR, TRP, TSP, and block1.
- All 33 Tenrouirai mission definitions are hardcoded and served correctly.
- Guild Tenrouirai progress (page, aggregated mission scores) is read and score-capped.
- Per-character Tenrouirai leaderboard (DataType=5) is read from DB.
- Guild tower RP donation (Op=2) deducts player RP, accumulates guild RP, and advances
the mission page when the threshold is met.
- Gem inventory (QueryType=1) is read and returned correctly.
- Gem add (Op=1) updates the CSV at the correct index.
---
## What Needs RE or Implementation
### Functional bugs (affect gameplay today)
| Issue | Location | Notes |
|-------|----------|-------|
| `PostTenrouirai` Op=1 is a no-op | `handlers_tower.go` | Mission scores are never written; Tenrouirai cannot advance via normal play |
| `block2` never written | `repo_tower.go → UpdateProgress` | G7+ floor count not persisted; requires captures to confirm which InfoType sends it |
| `PresentBox` returns empty list | `handlers_tower.go` | No items are ever shown; `wip/tower` handler logic can be adapted |
| Present claim tracking absent | DB schema | No "claimed" flag; players can re-claim indefinitely once handler is populated |
### Unknown packet fields (need captures to resolve)
| Field | Packet | Notes |
|-------|--------|-------|
| `Unk9 int64` vs `TimeTaken int32 + CID int32` | `MsgMhfPostTowerInfo` | `wip/tower` splits this into two int32; likely correct — needs capture confirmation |
| `Unk1`, `Unk6`, `Unk7` | `MsgMhfPostTowerInfo` | Logged in debug but unused; may encode run metadata |
| `Unk0`, `Unk1` | `MsgMhfGetTowerInfo` | Never used in handler |
| `TowerInfoLevel.Unk1Unk3` | `GetTowerInfo` InfoType=3,5 | 3 of 4 level-entry fields zero-filled; may be max floor, session count, display state |
| `TowerInfoHistory` 10 × int16 | `GetTowerInfo` InfoType=4 | Two groups of 5; semantics unknown (recent clear times? floor high scores?) |
| InfoType 3 vs 5 distinction | `MsgMhfGetTowerInfo` | Same code path; difference not understood |
| InfoType 7 vs 1 distinction | `MsgMhfPostTowerInfo` | Both update progress; difference not understood |
| `Unk0`, `Unk4` | `MsgMhfGetTenrouirai` | Always 0 in known captures |
| `TenrouiraiKeyScore` | `GetTenrouirai` | Struct defined, never sent; likely an unimplemented DataType |
| `Unk0`, `Unk1`, `Unk2_03` | `MsgMhfPostTenrouirai` | 6 parsed fields that are never used |
| `Unk1Unk6` | `MsgMhfGetGemInfo` | 6 extra fields; may filter by tier, season, or character |
| `Message`, `Unk1`, `Unk6` | `MsgMhfPostGemInfo` | `Message` likely a display string ID for gem transfer notices |
| `Unk0`, `Unk3Unk6` | `MsgMhfPresentBox` | 5 unknown request fields |
| All fields | `MsgMhfGetNotice`, `MsgMhfPostNotice` | Both packets entirely uncharacterized |
### Missing features (require further RE + design)
| Feature | Notes |
|---------|-------|
| Tenrouirai mission score submission | `PostTenrouirai` Op=1 needs to map run stats to the correct mission type and write `tower_mission_N` |
| Tenrouirai rewards (DataType=2) | `TenrouiraiReward` response format is known; item IDs and quantities are not |
| Gem transfer (PostGemInfo Op=2) | Recipient lookup via `CID`; likely requires a notification to the target session |
| Gem history (GetGemInfo QueryType=2) | Response structure is known; DB storage is not — would require a `gem_history` table |
| PresentBox claim tracking | Needs a `present_claims` table or a bitfield on the character |
| Notice system | Both Get/Post are stubs; may be Tower bulletin board or reward notifications |
---
## Relation to the Conquest War Doc
`docs/conquest-war.md` covers the `GetWeeklySeibatuRankingReward` handler which already
implements the Tower dure kill reward table (Op=5, ID 260001) and the Tower floor reward
table (Op=5, ID 260003). Those are not missing here — they live in `handlers_seibattle.go`
on the `feature/conquest` branch. When that branch is eventually integrated, ensure the
Tower floor reward data is preserved.