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

@@ -0,0 +1,169 @@
package decryption
import "encoding/binary"
// PackSimple compresses data using JPK type-3 (LZ77) compression and wraps it
// in a JKR header. It is the inverse of UnpackSimple.
func PackSimple(data []byte) []byte {
compressed := lzEncode(data)
out := make([]byte, 16+len(compressed))
binary.LittleEndian.PutUint32(out[0:4], 0x1A524B4A) // JKR magic
binary.LittleEndian.PutUint16(out[4:6], 0x0108) // version
binary.LittleEndian.PutUint16(out[6:8], 0x0003) // type 3 = LZ only
binary.LittleEndian.PutUint32(out[8:12], 0x00000010) // data offset = 16 (after header)
binary.LittleEndian.PutUint32(out[12:16], uint32(len(data)))
copy(out[16:], compressed)
return out
}
// lzEncoder holds mutable state for the LZ77 compression loop.
// Ported from ReFrontier JPKEncodeLz.cs.
//
// The format groups 8 items behind a flag byte (MSB = item 0):
//
// bit=0 → literal byte follows
// bit=1 → back-reference follows (with sub-cases below)
//
// Back-reference sub-cases:
//
// 10xx + 1 byte → length 36, offset ≤ 255
// 11 + 2 bytes → length 39, offset ≤ 8191 (length encoded in hi byte bits 75)
// 11 + 2 bytes + 0 + 4 bits → length 1025, offset ≤ 8191
// 11 + 2 bytes + 1 + 1 byte → length 26280, offset ≤ 8191
type lzEncoder struct {
flag byte
shiftIndex int
toWrite [1024]byte // data bytes for the current flag group
indexToWrite int
out []byte
}
func (e *lzEncoder) setFlag(value bool) {
if e.shiftIndex <= 0 {
e.flushFlag(false)
e.shiftIndex = 7
} else {
e.shiftIndex--
}
if value {
e.flag |= 1 << uint(e.shiftIndex)
}
}
// setFlagsReverse writes `count` bits of value MSB-first.
func (e *lzEncoder) setFlagsReverse(value byte, count int) {
for i := count - 1; i >= 0; i-- {
e.setFlag(((value >> uint(i)) & 1) == 1)
}
}
func (e *lzEncoder) writeByte(b byte) {
e.toWrite[e.indexToWrite] = b
e.indexToWrite++
}
func (e *lzEncoder) flushFlag(final bool) {
if !final || e.indexToWrite > 0 {
e.out = append(e.out, e.flag)
}
e.flag = 0
e.out = append(e.out, e.toWrite[:e.indexToWrite]...)
e.indexToWrite = 0
}
// lzEncode compresses data with the JPK LZ77 algorithm, producing the raw
// compressed bytes (without the JKR header).
func lzEncode(data []byte) []byte {
const (
compressionLevel = 280 // max match length
maxIndexDist = 0x300 // max look-back distance (768)
)
enc := &lzEncoder{shiftIndex: 8}
for pos := 0; pos < len(data); {
repLen, repOff := lzLongestRepetition(data, pos, compressionLevel, maxIndexDist)
if repLen == 0 {
// Literal byte
enc.setFlag(false)
enc.writeByte(data[pos])
pos++
} else {
enc.setFlag(true)
if repLen <= 6 && repOff <= 0xff {
// Short: flag=10, 2-bit length, 1-byte offset
enc.setFlag(false)
enc.setFlagsReverse(byte(repLen-3), 2)
enc.writeByte(byte(repOff))
} else {
// Long: flag=11, 2-byte offset/length header
enc.setFlag(true)
u16 := uint16(repOff)
if repLen <= 9 {
// Length fits in hi byte bits 7-5
u16 |= uint16(repLen-2) << 13
}
enc.writeByte(byte(u16 >> 8))
enc.writeByte(byte(u16 & 0xff))
if repLen > 9 {
if repLen <= 25 {
// Extended: flag=0, 4-bit length
enc.setFlag(false)
enc.setFlagsReverse(byte(repLen-10), 4)
} else {
// Extended: flag=1, 1-byte length
enc.setFlag(true)
enc.writeByte(byte(repLen - 0x1a))
}
}
}
pos += repLen
}
}
enc.flushFlag(true)
return enc.out
}
// lzLongestRepetition finds the longest match for data[pos:] in the look-back
// window. Returns (matchLen, encodedOffset) where encodedOffset is
// (pos - matchStart - 1). Returns (0, 0) when no usable match exists.
func lzLongestRepetition(data []byte, pos, compressionLevel, maxIndexDist int) (int, uint) {
const minLength = 3
// Clamp threshold to available bytes
threshold := compressionLevel
if remaining := len(data) - pos; remaining < threshold {
threshold = remaining
}
if pos == 0 || threshold < minLength {
return 0, 0
}
windowStart := pos - maxIndexDist
if windowStart < 0 {
windowStart = 0
}
maxLen := 0
var bestOffset uint
for left := windowStart; left < pos; left++ {
curLen := 0
for curLen < threshold && data[left+curLen] == data[pos+curLen] {
curLen++
}
if curLen >= minLength && curLen > maxLen {
maxLen = curLen
bestOffset = uint(pos - left - 1)
if maxLen >= threshold {
break
}
}
}
return maxLen, bestOffset
}