Files
Erupe/server/channelserver/scenario_json_test.go
Houmgaor a1dfdd330a 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.
2026-03-20 13:55:40 +01:00

373 lines
11 KiB
Go

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)
}
}