feat(scenario): add JSON scenario support and JKR type-3 compressor

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.
This commit is contained in:
Houmgaor
2026-03-20 13:55:40 +01:00
parent 71b675bf3e
commit a1dfdd330a
8 changed files with 1226 additions and 27 deletions

View File

@@ -106,10 +106,9 @@ func handleMsgSysGetFile(s *Session, p mhfpacket.MHFPacket) {
)
}
filename := fmt.Sprintf("%d_0_0_0_S%d_T%d_C%d", pkt.ScenarioIdentifer.CategoryID, pkt.ScenarioIdentifer.MainID, pkt.ScenarioIdentifer.Flags, pkt.ScenarioIdentifer.ChapterID)
// Read the scenario file.
data, err := os.ReadFile(filepath.Join(s.server.erupeConfig.BinPath, fmt.Sprintf("scenarios/%s.bin", filename)))
data, err := loadScenarioBinary(s, filename)
if err != nil {
s.logger.Error("Failed to open scenario file", zap.String("binPath", s.server.erupeConfig.BinPath), zap.String("filename", filename))
s.logger.Error("Failed to open scenario file", zap.String("binPath", s.server.erupeConfig.BinPath), zap.String("filename", filename), zap.Error(err))
doAckBufFail(s, pkt.AckHandle, nil)
return
}
@@ -168,6 +167,26 @@ func loadQuestBinary(s *Session, filename string) ([]byte, error) {
return compiled, nil
}
// loadScenarioBinary loads a scenario file by name, trying .bin first then .json.
// For .json files it compiles the JSON to the MHF binary wire format.
func loadScenarioBinary(s *Session, filename string) ([]byte, error) {
base := filepath.Join(s.server.erupeConfig.BinPath, "scenarios", filename)
if data, err := os.ReadFile(base + ".bin"); err == nil {
return data, nil
}
jsonData, err := os.ReadFile(base + ".json")
if err != nil {
return nil, err
}
compiled, err := CompileScenarioJSON(jsonData)
if err != nil {
return nil, fmt.Errorf("compile scenario JSON %s: %w", filename, err)
}
return compiled, nil
}
func seasonConversion(s *Session, questFile string) string {
// Try the seasonal override file (e.g., 00001d2 for season 2)
filename := fmt.Sprintf("%s%d", questFile[:6], s.server.Season())

View File

@@ -0,0 +1,432 @@
package channelserver
import (
"bytes"
"encoding/base64"
"encoding/binary"
"encoding/json"
"fmt"
"golang.org/x/text/encoding/japanese"
"golang.org/x/text/transform"
)
// ── 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]
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)
// @1: u8 0x00 (pad; distinguishes this format from inline)
// @2: u16 Size (total chunk size including this header)
// @4: u8 Count (number of string entries)
// @5: u8 Unknown1
// @6: u8 MetaSize (total bytes of metadata block)
// @7: u8 Unknown2
// [MetaSize bytes: opaque metadata (string IDs, offsets, flags — partially unknown)]
// [null-terminated Shift-JIS strings, one per entry]
// [0xFF end-of-strings sentinel]
type ScenarioSubheaderJSON struct {
// Type is the chunk type byte (almost always 0x01).
Type uint8 `json:"type"`
Unknown1 uint8 `json:"unknown1"`
Unknown2 uint8 `json:"unknown2"`
// Metadata is the opaque metadata block, base64-encoded.
// Preserving it unchanged ensures correct client behavior for fields
// whose meaning is not yet fully understood.
Metadata string `json:"metadata"`
// Strings contains the human-editable text (UTF-8).
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.
func parseScenarioChunk1(data []byte) (*ScenarioChunk1JSON, error) {
if len(data) >= 4 && binary.LittleEndian.Uint32(data[0:4]) == 0x1A524B4A {
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))
}
// Sub-header fields
chunkType := data[0]
// data[1] is the 0x00 pad (not stored; implicit)
// data[2:4] is the u16 LE total size (recomputed on compile)
entryCount := int(data[4])
unknown1 := data[5]
metaSize := int(data[6])
unknown2 := data[7]
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
}

View File

@@ -0,0 +1,372 @@
package channelserver
import (
"bytes"
"encoding/binary"
"encoding/json"
"testing"
)
// ── test helpers ─────────────────────────────────────────────────────────────
// buildTestSubheaderChunk constructs a minimal sub-header format chunk.
// metadata is zero-filled to metaSize bytes.
func buildTestSubheaderChunk(t *testing.T, strings []string, metaSize int) []byte {
t.Helper()
var strBuf bytes.Buffer
for _, s := range strings {
sjis, err := scenarioEncodeShiftJIS(s)
if err != nil {
t.Fatalf("encode %q: %v", s, err)
}
strBuf.Write(sjis)
}
strBuf.WriteByte(0xFF) // end sentinel
totalSize := 8 + metaSize + strBuf.Len()
meta := make([]byte, metaSize) // zero metadata
var buf bytes.Buffer
buf.WriteByte(0x01) // type
buf.WriteByte(0x00) // pad
buf.WriteByte(byte(totalSize)) // size lo
buf.WriteByte(byte(totalSize >> 8)) // size hi
buf.WriteByte(byte(len(strings))) // entry count
buf.WriteByte(0x00) // unknown1
buf.WriteByte(byte(metaSize)) // metadata total
buf.WriteByte(0x00) // unknown2
buf.Write(meta)
buf.Write(strBuf.Bytes())
return buf.Bytes()
}
// buildTestInlineChunk constructs an inline-format chunk0.
func buildTestInlineChunk(t *testing.T, strings []string) []byte {
t.Helper()
var buf bytes.Buffer
for i, s := range strings {
buf.WriteByte(byte(i + 1)) // 1-based index
sjis, err := scenarioEncodeShiftJIS(s)
if err != nil {
t.Fatalf("encode %q: %v", s, err)
}
buf.Write(sjis)
}
return buf.Bytes()
}
// buildTestScenarioBinary assembles a complete scenario container for testing.
func buildTestScenarioBinary(t *testing.T, c0, c1 []byte) []byte {
t.Helper()
var buf bytes.Buffer
if err := binary.Write(&buf, binary.BigEndian, uint32(len(c0))); err != nil {
t.Fatal(err)
}
if err := binary.Write(&buf, binary.BigEndian, uint32(len(c1))); err != nil {
t.Fatal(err)
}
buf.Write(c0)
buf.Write(c1)
// c2 size = 0
if err := binary.Write(&buf, binary.BigEndian, uint32(0)); err != nil {
t.Fatal(err)
}
return buf.Bytes()
}
// extractStringsFromScenario parses a binary and returns all strings it contains.
func extractStringsFromScenario(t *testing.T, data []byte) []string {
t.Helper()
s, err := ParseScenarioBinary(data)
if err != nil {
t.Fatalf("ParseScenarioBinary: %v", err)
}
var result []string
if s.Chunk0 != nil {
if s.Chunk0.Subheader != nil {
result = append(result, s.Chunk0.Subheader.Strings...)
}
for _, e := range s.Chunk0.Inline {
result = append(result, e.Text)
}
}
if s.Chunk1 != nil && s.Chunk1.Subheader != nil {
result = append(result, s.Chunk1.Subheader.Strings...)
}
return result
}
// ── parse tests ──────────────────────────────────────────────────────────────
func TestParseScenarioBinary_TooShort(t *testing.T) {
_, err := ParseScenarioBinary([]byte{0x00, 0x01})
if err == nil {
t.Error("expected error for short input")
}
}
func TestParseScenarioBinary_EmptyChunks(t *testing.T) {
data := buildTestScenarioBinary(t, nil, nil)
s, err := ParseScenarioBinary(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.Chunk0 != nil || s.Chunk1 != nil || s.Chunk2 != nil {
t.Error("expected all chunks nil for empty scenario")
}
}
func TestParseScenarioBinary_SubheaderChunk0(t *testing.T) {
c0 := buildTestSubheaderChunk(t, []string{"Quest A", "Quest B"}, 4)
data := buildTestScenarioBinary(t, c0, nil)
s, err := ParseScenarioBinary(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.Chunk0 == nil || s.Chunk0.Subheader == nil {
t.Fatal("expected chunk0 subheader")
}
got := s.Chunk0.Subheader.Strings
want := []string{"Quest A", "Quest B"}
if len(got) != len(want) {
t.Fatalf("string count: got %d, want %d", len(got), len(want))
}
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d]: got %q, want %q", i, got[i], want[i])
}
}
}
func TestParseScenarioBinary_InlineChunk0(t *testing.T) {
c0 := buildTestInlineChunk(t, []string{"Item1", "Item2"})
data := buildTestScenarioBinary(t, c0, nil)
s, err := ParseScenarioBinary(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if s.Chunk0 == nil || len(s.Chunk0.Inline) == 0 {
t.Fatal("expected chunk0 inline entries")
}
want := []string{"Item1", "Item2"}
for i, e := range s.Chunk0.Inline {
if e.Text != want[i] {
t.Errorf("[%d]: got %q, want %q", i, e.Text, want[i])
}
}
}
func TestParseScenarioBinary_BothChunks(t *testing.T) {
c0 := buildTestSubheaderChunk(t, []string{"Quest"}, 4)
c1 := buildTestSubheaderChunk(t, []string{"NPC1", "NPC2"}, 8)
data := buildTestScenarioBinary(t, c0, c1)
strings := extractStringsFromScenario(t, data)
want := []string{"Quest", "NPC1", "NPC2"}
if len(strings) != len(want) {
t.Fatalf("string count: got %d, want %d", len(strings), len(want))
}
for i := range want {
if strings[i] != want[i] {
t.Errorf("[%d]: got %q, want %q", i, strings[i], want[i])
}
}
}
func TestParseScenarioBinary_Japanese(t *testing.T) {
c0 := buildTestSubheaderChunk(t, []string{"テスト", "日本語"}, 4)
data := buildTestScenarioBinary(t, c0, nil)
s, err := ParseScenarioBinary(data)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := []string{"テスト", "日本語"}
got := s.Chunk0.Subheader.Strings
for i := range want {
if got[i] != want[i] {
t.Errorf("[%d]: got %q, want %q", i, got[i], want[i])
}
}
}
// ── compile tests ─────────────────────────────────────────────────────────────
func TestCompileScenarioJSON_Subheader(t *testing.T) {
input := &ScenarioJSON{
Chunk0: &ScenarioChunk0JSON{
Subheader: &ScenarioSubheaderJSON{
Type: 0x01,
Unknown1: 0x00,
Unknown2: 0x00,
Metadata: "AAAABBBB", // base64 of 6 zero bytes
Strings: []string{"Hello", "World"},
},
},
}
jsonData, err := json.Marshal(input)
if err != nil {
t.Fatalf("marshal: %v", err)
}
compiled, err := CompileScenarioJSON(jsonData)
if err != nil {
t.Fatalf("CompileScenarioJSON: %v", err)
}
// Parse the compiled output and verify strings survive
result, err := ParseScenarioBinary(compiled)
if err != nil {
t.Fatalf("ParseScenarioBinary on compiled output: %v", err)
}
if result.Chunk0 == nil || result.Chunk0.Subheader == nil {
t.Fatal("expected chunk0 subheader in compiled output")
}
want := []string{"Hello", "World"}
got := result.Chunk0.Subheader.Strings
for i := range want {
if i >= len(got) || got[i] != want[i] {
t.Errorf("[%d]: got %q, want %q", i, got[i], want[i])
}
}
}
func TestCompileScenarioJSON_Inline(t *testing.T) {
input := &ScenarioJSON{
Chunk0: &ScenarioChunk0JSON{
Inline: []ScenarioInlineEntry{
{Index: 1, Text: "Sword"},
{Index: 2, Text: "Shield"},
},
},
}
jsonData, _ := json.Marshal(input)
compiled, err := CompileScenarioJSON(jsonData)
if err != nil {
t.Fatalf("CompileScenarioJSON: %v", err)
}
result, err := ParseScenarioBinary(compiled)
if err != nil {
t.Fatalf("ParseScenarioBinary: %v", err)
}
if result.Chunk0 == nil || len(result.Chunk0.Inline) != 2 {
t.Fatal("expected 2 inline entries")
}
if result.Chunk0.Inline[0].Text != "Sword" {
t.Errorf("got %q, want Sword", result.Chunk0.Inline[0].Text)
}
if result.Chunk0.Inline[1].Text != "Shield" {
t.Errorf("got %q, want Shield", result.Chunk0.Inline[1].Text)
}
}
// ── round-trip tests ─────────────────────────────────────────────────────────
func TestScenarioRoundTrip_Subheader(t *testing.T) {
original := buildTestScenarioBinary(t,
buildTestSubheaderChunk(t, []string{"QuestName", "Description"}, 0x14),
buildTestSubheaderChunk(t, []string{"Dialog1", "Dialog2", "Dialog3"}, 0x2C),
)
s, err := ParseScenarioBinary(original)
if err != nil {
t.Fatalf("parse: %v", err)
}
jsonData, err := json.Marshal(s)
if err != nil {
t.Fatalf("marshal: %v", err)
}
compiled, err := CompileScenarioJSON(jsonData)
if err != nil {
t.Fatalf("compile: %v", err)
}
// Re-parse compiled and compare strings
wantStrings := []string{"QuestName", "Description", "Dialog1", "Dialog2", "Dialog3"}
gotStrings := extractStringsFromScenario(t, compiled)
if len(gotStrings) != len(wantStrings) {
t.Fatalf("string count: got %d, want %d", len(gotStrings), len(wantStrings))
}
for i := range wantStrings {
if gotStrings[i] != wantStrings[i] {
t.Errorf("[%d]: got %q, want %q", i, gotStrings[i], wantStrings[i])
}
}
}
func TestScenarioRoundTrip_Inline(t *testing.T) {
original := buildTestScenarioBinary(t,
buildTestInlineChunk(t, []string{"EpisodeA", "EpisodeB"}),
nil,
)
s, _ := ParseScenarioBinary(original)
jsonData, _ := json.Marshal(s)
compiled, err := CompileScenarioJSON(jsonData)
if err != nil {
t.Fatalf("compile: %v", err)
}
got := extractStringsFromScenario(t, compiled)
want := []string{"EpisodeA", "EpisodeB"}
for i := range want {
if i >= len(got) || got[i] != want[i] {
t.Errorf("[%d]: got %q, want %q", i, got[i], want[i])
}
}
}
func TestScenarioRoundTrip_MetadataPreserved(t *testing.T) {
// The metadata block must survive parse → JSON → compile unchanged.
metaBytes := []byte{0x01, 0x02, 0x03, 0x04, 0xFF, 0xFE, 0xFD, 0xFC}
// Build a chunk with custom metadata and unknown field values by hand.
var buf bytes.Buffer
str := []byte("A\x00\xFF")
totalSize := 8 + len(metaBytes) + len(str)
buf.WriteByte(0x01)
buf.WriteByte(0x00)
buf.WriteByte(byte(totalSize))
buf.WriteByte(byte(totalSize >> 8))
buf.WriteByte(0x01) // entry count
buf.WriteByte(0xAA) // unknown1
buf.WriteByte(byte(len(metaBytes)))
buf.WriteByte(0xBB) // unknown2
buf.Write(metaBytes)
buf.Write(str)
c0 := buf.Bytes()
data := buildTestScenarioBinary(t, c0, nil)
s, err := ParseScenarioBinary(data)
if err != nil {
t.Fatalf("parse: %v", err)
}
sh := s.Chunk0.Subheader
if sh.Type != 0x01 || sh.Unknown1 != 0xAA || sh.Unknown2 != 0xBB {
t.Errorf("header fields: type=%02X unk1=%02X unk2=%02X", sh.Type, sh.Unknown1, sh.Unknown2)
}
// Compile and parse again — metadata must survive
jsonData, _ := json.Marshal(s)
compiled, err := CompileScenarioJSON(jsonData)
if err != nil {
t.Fatalf("compile: %v", err)
}
s2, err := ParseScenarioBinary(compiled)
if err != nil {
t.Fatalf("re-parse: %v", err)
}
sh2 := s2.Chunk0.Subheader
if sh2.Metadata != sh.Metadata {
t.Errorf("metadata changed:\n before: %s\n after: %s", sh.Metadata, sh2.Metadata)
}
if sh2.Unknown1 != sh.Unknown1 || sh2.Unknown2 != sh.Unknown2 {
t.Errorf("unknown fields changed: unk1 %02X→%02X unk2 %02X→%02X",
sh.Unknown1, sh2.Unknown1, sh.Unknown2, sh2.Unknown2)
}
}