Closes #172. Scenario files in bin/scenarios/ can now be authored as .json instead of .bin — the server compiles them to wire format on load, falling back to .bin if no .json is present. - Add ParseScenarioBinary / CompileScenarioJSON in scenario_json.go; supports sub-header format (strings as UTF-8, metadata as base64), inline format, and raw JKR blobs. - Add PackSimple JKR type-3 (LZ77) compressor in jpk_compress.go, ported from ReFrontier JPKEncodeLz.cs; round-trip tested against UnpackSimple. - Fix off-by-one in processDecode (jpk.go): last literal byte was silently dropped for data that does not end on a back-reference. - Wire loadScenarioBinary into handleMsgSysGetFile replacing the inline os.ReadFile call; mirrors the existing loadQuestBinary pattern. - Rewrite docs/scenario-format.md with full container/sub-header spec and JSON schema examples.
6.7 KiB
Scenario Binary Format
Reference:
network/mhfpacket/msg_sys_get_file.go, issue #172
Overview
Scenario files are binary blobs served by MSG_SYS_GET_FILE when IsScenario is true. They contain quest descriptions, NPC dialog, episode listings, and menu options for the game's scenario/story system.
Request Format
When IsScenario == true, the client sends a scenarioFileIdentifier:
| Offset | Type | Field | Description |
|---|---|---|---|
| 0 | uint8 | CategoryID | Scenario category (0=Basic, 1=Veteran, 3=Other, 6=Pallone, 7=Diva) |
| 1 | uint32 | MainID | Main scenario identifier |
| 5 | uint8 | ChapterID | Chapter within the scenario |
| 6 | uint8 | Flags | Bit flags selecting chunk types (see below) |
The server constructs the filename as:
{CategoryID}_0_0_0_S{MainID}_T{Flags}_C{ChapterID}.bin (or .json)
Flags (Chunk Type Selection)
The Flags byte is a bitmask that selects which chunk types the client requests:
| Bit | Value | Format | Content |
|---|---|---|---|
| 0 | 0x01 | Sub-header | Quest name/description (chunk0) |
| 1 | 0x02 | Sub-header | NPC dialog (chunk1) |
| 2 | 0x04 | — | Unknown (no instances found) |
| 3 | 0x08 | Inline | Episode listing (chunk0 inline) |
| 4 | 0x10 | JKR-compressed | NPC dialog blob (chunk1) |
| 5 | 0x20 | JKR-compressed | Menu options or quest titles (chunk2) |
| 6 | 0x40 | — | Unknown (no instances found) |
| 7 | 0x80 | — | Unknown (no instances found) |
The flags are part of the filename — each unique (CategoryID, MainID, Flags, ChapterID) tuple corresponds to its own file on disk.
Container Format (big-endian)
Offset Field
@0x00 u32 BE chunk0_size
@0x04 u32 BE chunk1_size
@0x08 bytes chunk0_data (chunk0_size bytes)
@0x08+c0 bytes chunk1_data (chunk1_size bytes)
@0x08+c0+c1 u32 BE chunk2_size (only present if file continues)
bytes chunk2_data (chunk2_size bytes)
The 8-byte header is always present. Chunks with size 0 are absent. Chunk2 is only read if at least 4 bytes remain after chunk0+chunk1.
Chunk Formats
Sub-header Format (flags 0x01, 0x02)
Used for structured text chunks containing named strings with metadata.
Sub-header (8 bytes, fields at byte offsets within the chunk):
| Off | Type | Field | Notes |
|---|---|---|---|
| 0 | u8 | Type | Usually 0x01 |
| 1 | u8 | Pad | Always 0x00; used to detect this format vs inline |
| 2 | u16 LE | TotalSize | Total chunk size including this header |
| 4 | u8 | EntryCount | Number of string entries |
| 5 | u8 | Unknown1 | Unknown; preserved in JSON for round-trip |
| 6 | u8 | MetadataSize | Total bytes of the metadata block that follows |
| 7 | u8 | Unknown2 | Unknown; preserved in JSON for round-trip |
Layout after the 8-byte header:
[MetadataSize bytes: opaque metadata block]
[null-terminated Shift-JIS string #1]
[null-terminated Shift-JIS string #2]
...
[0xFF end-of-strings sentinel]
Metadata block (partially understood):
The metadata block is MetadataSize bytes long and covers all entries collectively. Known sizes observed in real files:
- Chunk0 (flag 0x01):
MetadataSize = 0x14(20 bytes) - Chunk1 (flag 0x02):
MetadataSize = 0x2C(44 bytes)
The internal structure of the metadata is not yet fully documented. It is preserved verbatim in the JSON format as a base64 blob so that clients receive correct values even for unknown fields.
Format detection for chunk0: if chunk_data[1] == 0x00 → sub-header, else → inline.
Inline Format (flag 0x08)
Used for episode listings. Each entry is:
{u8 index}{null-terminated Shift-JIS string}
Entries are sequential with no separator. Null bytes between entries are ignored during parsing.
JKR-compressed Chunks (flags 0x10, 0x20)
Chunks with flags 0x10 (chunk1) and 0x20 (chunk2) are JKR-compressed blobs. The JKR header (magic 0x1A524B4A) appears at the start of the chunk data.
The decompressed content contains metadata bytes interleaved with null-terminated Shift-JIS strings, but the detailed format is not yet fully documented. These chunks are stored as opaque base64 blobs in the JSON format and served to the client unchanged.
JSON Format (for .json scenario files)
Erupe supports .json files in bin/scenarios/ as an alternative to .bin files. The server compiles .json to wire format on demand. .bin takes priority if both exist.
Example 0_0_0_0_S102_T1_C0.json:
{
"chunk0": {
"subheader": {
"type": 1,
"unknown1": 0,
"unknown2": 0,
"metadata": "AAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"strings": ["Quest Name", "Quest description goes here."]
}
}
}
Example with inline chunk0 (flag 0x08):
{
"chunk0": {
"inline": [
{"index": 1, "text": "Chapter 1"},
{"index": 2, "text": "Chapter 2"}
]
}
}
Example with both chunk0 and chunk1:
{
"chunk0": {
"subheader": {
"type": 1, "unknown1": 0, "unknown2": 0,
"metadata": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"strings": ["Quest Name"]
}
},
"chunk1": {
"subheader": {
"type": 1, "unknown1": 0, "unknown2": 0,
"metadata": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
"strings": ["NPC: Welcome, hunter.", "NPC: Good luck!"]
}
}
}
Key fields:
metadata: Base64-encoded opaque blob. Copy fromParseScenarioBinaryoutput. For new scenarios with zero-filled metadata, use a base64 string of the right number of zero bytes.strings: UTF-8 text. The compiler converts to Shift-JIS on the wire.chunk2.data: Raw JKR-compressed bytes, base64-encoded. Copy from the original.binfile.
JKR Compression
Chunks with flags 0x10 and 0x20 use JKR compression (magic 0x1A524B4A, type 3 LZ77). The Go compressor is in common/decryption.PackSimple and the decompressor in common/decryption.UnpackSimple. These implement type-3 (LZ-only) compression, which is the format used throughout Erupe.
Type-4 (HFI = Huffman + LZ77) JKR blobs from real game files pass through as opaque base64 in .json — the server serves them as-is without re-compression.
Implementation
- Handler:
server/channelserver/handlers_quest.go→handleMsgSysGetFile→loadScenarioBinary - JSON schema + compiler:
server/channelserver/scenario_json.go - JKR compressor:
common/decryption/jpk_compress.go(PackSimple) - JKR decompressor:
common/decryption/jpk.go(UnpackSimple)