package channelserver import ( "bytes" "encoding/base64" "encoding/binary" "encoding/json" "fmt" "golang.org/x/text/encoding/japanese" "golang.org/x/text/transform" ) // ── Constants ───────────────────────────────────────────────────────────────── // jkrMagic is the little-endian magic number at the start of a JKR-compressed // blob: bytes 0x4A 0x4B 0x52 0x1A ('J','K','R',0x1A). const jkrMagic uint32 = 0x1A524B4A // scenarioChunkSizeLimit is the maximum byte length the client accepts for any // single chunk (chunk0, chunk1, or chunk2). Confirmed from the client's response // handler (FUN_11525c60 in mhfo-hd.dll): chunks larger than this are silently // discarded, so the server must never serve a chunk exceeding this limit. const scenarioChunkSizeLimit = 0x8000 // ── JSON schema types ──────────────────────────────────────────────────────── // ScenarioJSON is the open, human-editable representation of a scenario .bin file. // Strings are stored as UTF-8; the compiler converts to/from Shift-JIS. // // Container layout (big-endian sizes): // // @0x00: u32 BE chunk0_size // @0x04: u32 BE chunk1_size // [chunk0_data] // [chunk1_data] // u32 BE chunk2_size (only present when non-zero) // [chunk2_data] // // Each chunk must not exceed scenarioChunkSizeLimit bytes. type ScenarioJSON struct { // Chunk0 holds quest name/description data (sub-header or inline format). Chunk0 *ScenarioChunk0JSON `json:"chunk0,omitempty"` // Chunk1 holds NPC dialog data (sub-header format or raw JKR blob). Chunk1 *ScenarioChunk1JSON `json:"chunk1,omitempty"` // Chunk2 holds JKR-compressed menu/title data. Chunk2 *ScenarioRawChunkJSON `json:"chunk2,omitempty"` } // ScenarioChunk0JSON represents chunk0, which is either sub-header or inline format. // Exactly one of Subheader/Inline is non-nil. type ScenarioChunk0JSON struct { Subheader *ScenarioSubheaderJSON `json:"subheader,omitempty"` Inline []ScenarioInlineEntry `json:"inline,omitempty"` } // ScenarioChunk1JSON represents chunk1, which is either sub-header or raw JKR. // Exactly one of Subheader/JKR is non-nil. type ScenarioChunk1JSON struct { Subheader *ScenarioSubheaderJSON `json:"subheader,omitempty"` JKR *ScenarioRawChunkJSON `json:"jkr,omitempty"` } // ScenarioSubheaderJSON represents a chunk in sub-header format. // // Sub-header binary layout (8 bytes, little-endian where applicable): // // @0: u8 Type (usually 0x01; the client treats this as a compound-container tag) // @1: u8 0x00 (pad; must be 0x00 — used by the server to detect this format vs inline) // @2: u16 Size (total chunk size including this header, LE) // @4: u8 Count (number of string entries) // @5: u8 Unknown1 (purpose unconfirmed; preserved round-trip) // @6: u8 MetaSize (byte length of the metadata block; 0x14 for chunk0, 0x2C for chunk1) // @7: u8 Unknown2 (purpose unconfirmed; preserved round-trip) // [MetaSize bytes: opaque metadata — see docs/scenario-format.md for field breakdown] // [null-terminated Shift-JIS strings, one per entry] // [0xFF end-of-strings sentinel] // // Chunk0 metadata (MetaSize=0x14, 10×u16 LE): // // m[0]=CategoryID m[1]=MainID m[2]=0 m[3]=0 m[4]=0 // m[5]=str0_len m[6]=SceneRef (MainID when cat=0, 0xFFFF otherwise) // m[7..9]: not read by the client parser (FUN_1080d310 in mhfo-hd.dll) // // Chunk1 metadata (MetaSize=0x2C, 22×u16 LE): // // m[8..17] are interpreted as signed offsets by the client (FUN_1080d3b0): // negative → (~value) + dialog_base (into post-0xFF dialog script) // non-negative → value + strings_base (into strings section) // m[18..19] are read as individual bytes, not u16 pairs. type ScenarioSubheaderJSON struct { // Type is the chunk type byte (almost always 0x01). Type uint8 `json:"type"` // Unknown1 is the byte at sub-header offset 5. Purpose not confirmed; // always 0x00 in observed files. Unknown1 uint8 `json:"unknown1"` // Unknown2 is the byte at sub-header offset 7. Purpose not confirmed; // always 0x00 in observed files. Unknown2 uint8 `json:"unknown2"` // Metadata is the opaque metadata block, base64-encoded. // It is preserved verbatim so the client receives correct values for all // fields, including those the server does not need to interpret. // For chunk0, the client only reads m[0]–m[6]; m[7]–m[9] are ignored. Metadata string `json:"metadata"` // Strings contains the human-editable text (UTF-8). // The compiler converts each string to null-terminated Shift-JIS on the wire. Strings []string `json:"strings"` } // ScenarioInlineEntry is one entry in an inline-format chunk0. // Format on wire: {u8 index}{Shift-JIS string}{0x00}. type ScenarioInlineEntry struct { Index uint8 `json:"index"` Text string `json:"text"` } // ScenarioRawChunkJSON stores a JKR-compressed chunk as its raw compressed bytes. // The data is served to the client as-is; the format of the decompressed content // is not yet fully documented. type ScenarioRawChunkJSON struct { // Data is the raw JKR-compressed bytes, base64-encoded. Data string `json:"data"` } // ── Parse: binary → JSON ───────────────────────────────────────────────────── // ParseScenarioBinary reads a scenario .bin file and returns a ScenarioJSON // suitable for editing and re-compilation with CompileScenarioJSON. func ParseScenarioBinary(data []byte) (*ScenarioJSON, error) { if len(data) < 8 { return nil, fmt.Errorf("scenario data too short: %d bytes", len(data)) } c0Size := int(binary.BigEndian.Uint32(data[0:4])) c1Size := int(binary.BigEndian.Uint32(data[4:8])) result := &ScenarioJSON{} // Chunk0 c0Off := 8 if c0Size > 0 { if c0Off+c0Size > len(data) { return nil, fmt.Errorf("chunk0 size %d overruns data at offset %d", c0Size, c0Off) } chunk0, err := parseScenarioChunk0(data[c0Off : c0Off+c0Size]) if err != nil { return nil, fmt.Errorf("chunk0: %w", err) } result.Chunk0 = chunk0 } // Chunk1 c1Off := c0Off + c0Size if c1Size > 0 { if c1Off+c1Size > len(data) { return nil, fmt.Errorf("chunk1 size %d overruns data at offset %d", c1Size, c1Off) } chunk1, err := parseScenarioChunk1(data[c1Off : c1Off+c1Size]) if err != nil { return nil, fmt.Errorf("chunk1: %w", err) } result.Chunk1 = chunk1 } // Chunk2 (preceded by its own 4-byte size field) c2HdrOff := c1Off + c1Size if c2HdrOff+4 <= len(data) { c2Size := int(binary.BigEndian.Uint32(data[c2HdrOff : c2HdrOff+4])) if c2Size > 0 { c2DataOff := c2HdrOff + 4 if c2DataOff+c2Size > len(data) { return nil, fmt.Errorf("chunk2 size %d overruns data at offset %d", c2Size, c2DataOff) } result.Chunk2 = &ScenarioRawChunkJSON{ Data: base64.StdEncoding.EncodeToString(data[c2DataOff : c2DataOff+c2Size]), } } } return result, nil } // parseScenarioChunk0 auto-detects sub-header vs inline format. // The second byte being 0x00 is the pad byte in sub-headers; non-zero means inline. func parseScenarioChunk0(data []byte) (*ScenarioChunk0JSON, error) { if len(data) < 2 { return &ScenarioChunk0JSON{}, nil } if data[1] == 0x00 { sh, err := parseScenarioSubheader(data) if err != nil { return nil, err } return &ScenarioChunk0JSON{Subheader: sh}, nil } entries, err := parseScenarioInline(data) if err != nil { return nil, err } return &ScenarioChunk0JSON{Inline: entries}, nil } // parseScenarioChunk1 parses chunk1 as JKR or sub-header depending on magic bytes. // JKR-compressed chunks start with the magic 'J','K','R',0x1A (LE u32 = jkrMagic). func parseScenarioChunk1(data []byte) (*ScenarioChunk1JSON, error) { if len(data) >= 4 && binary.LittleEndian.Uint32(data[0:4]) == jkrMagic { return &ScenarioChunk1JSON{ JKR: &ScenarioRawChunkJSON{ Data: base64.StdEncoding.EncodeToString(data), }, }, nil } sh, err := parseScenarioSubheader(data) if err != nil { return nil, err } return &ScenarioChunk1JSON{Subheader: sh}, nil } // parseScenarioSubheader parses the 8-byte sub-header + metadata + strings. func parseScenarioSubheader(data []byte) (*ScenarioSubheaderJSON, error) { if len(data) < 8 { return nil, fmt.Errorf("sub-header chunk too short: %d bytes", len(data)) } // 8-byte sub-header fields: chunkType := data[0] // @0: chunk type (0x01 = compound container) // data[1] // @1: pad 0x00 (format detector; not stored) // data[2:4] // @2: u16 LE total size (recomputed on compile) entryCount := int(data[4]) // @4: number of string entries unknown1 := data[5] // @5: purpose unknown; always 0x00 in observed files metaSize := int(data[6]) // @6: byte length of metadata block (0x14=C0, 0x2C=C1) unknown2 := data[7] // @7: purpose unknown; always 0x00 in observed files metaEnd := 8 + metaSize if metaEnd > len(data) { return nil, fmt.Errorf("metadata block (size %d) overruns chunk (len %d)", metaSize, len(data)) } metadata := base64.StdEncoding.EncodeToString(data[8:metaEnd]) strings, err := scenarioReadStrings(data, metaEnd, entryCount) if err != nil { return nil, err } return &ScenarioSubheaderJSON{ Type: chunkType, Unknown1: unknown1, Unknown2: unknown2, Metadata: metadata, Strings: strings, }, nil } // parseScenarioInline parses chunk0 inline format: {u8 index}{Shift-JIS string}{0x00}. func parseScenarioInline(data []byte) ([]ScenarioInlineEntry, error) { var result []ScenarioInlineEntry pos := 0 for pos < len(data) { if data[pos] == 0x00 { pos++ continue } idx := data[pos] pos++ if pos >= len(data) { break } end := pos for end < len(data) && data[end] != 0x00 { end++ } if end > pos { text, err := scenarioDecodeShiftJIS(data[pos:end]) if err != nil { return nil, fmt.Errorf("inline entry at 0x%x: %w", pos, err) } result = append(result, ScenarioInlineEntry{Index: idx, Text: text}) } pos = end + 1 // skip null terminator } return result, nil } // scenarioReadStrings scans for null-terminated Shift-JIS strings starting at // offset start, reading at most maxCount strings (0 = unlimited). Stops on 0xFF. func scenarioReadStrings(data []byte, start, maxCount int) ([]string, error) { var result []string pos := start for pos < len(data) { if maxCount > 0 && len(result) >= maxCount { break } if data[pos] == 0x00 { pos++ continue } if data[pos] == 0xFF { break } end := pos for end < len(data) && data[end] != 0x00 { end++ } if end > pos { text, err := scenarioDecodeShiftJIS(data[pos:end]) if err != nil { return nil, fmt.Errorf("string at 0x%x: %w", pos, err) } result = append(result, text) } pos = end + 1 } return result, nil } // ── Compile: JSON → binary ─────────────────────────────────────────────────── // CompileScenarioJSON parses jsonData and compiles it to MHF scenario binary format. func CompileScenarioJSON(jsonData []byte) ([]byte, error) { var s ScenarioJSON if err := json.Unmarshal(jsonData, &s); err != nil { return nil, fmt.Errorf("unmarshal scenario JSON: %w", err) } return compileScenario(&s) } func compileScenario(s *ScenarioJSON) ([]byte, error) { var chunk0, chunk1, chunk2 []byte var err error if s.Chunk0 != nil { chunk0, err = compileScenarioChunk0(s.Chunk0) if err != nil { return nil, fmt.Errorf("chunk0: %w", err) } } if s.Chunk1 != nil { chunk1, err = compileScenarioChunk1(s.Chunk1) if err != nil { return nil, fmt.Errorf("chunk1: %w", err) } } if s.Chunk2 != nil { chunk2, err = compileScenarioRawChunk(s.Chunk2) if err != nil { return nil, fmt.Errorf("chunk2: %w", err) } } var buf bytes.Buffer // Container header: c0_size, c1_size (big-endian u32) _ = binary.Write(&buf, binary.BigEndian, uint32(len(chunk0))) _ = binary.Write(&buf, binary.BigEndian, uint32(len(chunk1))) buf.Write(chunk0) buf.Write(chunk1) // Chunk2 preceded by its own size field if len(chunk2) > 0 { _ = binary.Write(&buf, binary.BigEndian, uint32(len(chunk2))) buf.Write(chunk2) } return buf.Bytes(), nil } func compileScenarioChunk0(c *ScenarioChunk0JSON) ([]byte, error) { if c.Subheader != nil { return compileScenarioSubheader(c.Subheader) } return compileScenarioInline(c.Inline) } func compileScenarioChunk1(c *ScenarioChunk1JSON) ([]byte, error) { if c.JKR != nil { return compileScenarioRawChunk(c.JKR) } if c.Subheader != nil { return compileScenarioSubheader(c.Subheader) } return nil, nil } // compileScenarioSubheader builds the binary sub-header chunk: // [8-byte header][metadata][null-terminated Shift-JIS strings][0xFF] func compileScenarioSubheader(sh *ScenarioSubheaderJSON) ([]byte, error) { meta, err := base64.StdEncoding.DecodeString(sh.Metadata) if err != nil { return nil, fmt.Errorf("decode metadata base64: %w", err) } var strBuf bytes.Buffer for _, s := range sh.Strings { sjis, err := scenarioEncodeShiftJIS(s) if err != nil { return nil, err } strBuf.Write(sjis) // sjis already has null terminator from helper } strBuf.WriteByte(0xFF) // end-of-strings sentinel // Total size = 8-byte header + metadata + strings totalSize := 8 + len(meta) + strBuf.Len() var buf bytes.Buffer buf.WriteByte(sh.Type) buf.WriteByte(0x00) // pad (format detector) // u16 LE total size buf.WriteByte(byte(totalSize)) buf.WriteByte(byte(totalSize >> 8)) buf.WriteByte(byte(len(sh.Strings))) // entry count buf.WriteByte(sh.Unknown1) buf.WriteByte(byte(len(meta))) // metadata total size buf.WriteByte(sh.Unknown2) buf.Write(meta) buf.Write(strBuf.Bytes()) return buf.Bytes(), nil } // compileScenarioInline builds the inline-format chunk0 bytes. func compileScenarioInline(entries []ScenarioInlineEntry) ([]byte, error) { var buf bytes.Buffer for _, e := range entries { buf.WriteByte(e.Index) sjis, err := scenarioEncodeShiftJIS(e.Text) if err != nil { return nil, err } buf.Write(sjis) // includes null terminator } return buf.Bytes(), nil } // compileScenarioRawChunk decodes the base64 raw chunk bytes. // These are served to the client as-is (no re-compression). func compileScenarioRawChunk(rc *ScenarioRawChunkJSON) ([]byte, error) { data, err := base64.StdEncoding.DecodeString(rc.Data) if err != nil { return nil, fmt.Errorf("decode raw chunk base64: %w", err) } return data, nil } // ── String helpers ─────────────────────────────────────────────────────────── // scenarioDecodeShiftJIS converts a raw Shift-JIS byte slice to UTF-8 string. func scenarioDecodeShiftJIS(b []byte) (string, error) { dec := japanese.ShiftJIS.NewDecoder() out, _, err := transform.Bytes(dec, b) if err != nil { return "", fmt.Errorf("shift-jis decode: %w", err) } return string(out), nil } // scenarioEncodeShiftJIS converts a UTF-8 string to a null-terminated Shift-JIS byte slice. func scenarioEncodeShiftJIS(s string) ([]byte, error) { enc := japanese.ShiftJIS.NewEncoder() out, _, err := transform.Bytes(enc, []byte(s)) if err != nil { return nil, fmt.Errorf("shift-jis encode %q: %w", s, err) } return append(out, 0x00), nil }