Files
Erupe/server/channelserver/scenario_json.go
Houmgaor ea51c63e0a refactor(scenario): annotate binary format constants and field roles
Add jkrMagic and scenarioChunkSizeLimit named constants, and expand
comments throughout scenario_json.go to reflect confirmed client
behaviour: the 0x8000-byte per-chunk limit, which C0 metadata fields
the parser actually reads (m[0]–m[6] only), the signed-offset
encoding used for C1 m[8]–m[17], and per-byte roles in the 8-byte
sub-header. Inline magic literal replaced with the named constant.
2026-03-20 16:16:59 +01:00

467 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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
}