速查 › 壓縮格式

QFS / RefPack 壓縮

.big 封裝檔取出來的東西,很多還包著一層壓縮。 這是 EA 自家的 RefPack(也常被叫做 QFS)。解開它不難, 而且要壓回去時有個技巧,可以完全不用寫壓縮器

怎麼辨識

第二個位元組是不是 0xFB。是就是 RefPack。

10 FB 01 48 D7 E5 ...   ← 壓縮過
44 41 54 45 2C 54 ...   ← 沒壓縮(這是「DATE,T」的 ASCII)

同一個封裝檔裡壓縮和未壓縮可以混在一起。 這是 EA 出廠就有的:剛安裝好的原版 207 個封裝檔裡有 106 個是混合的 —— 例如 ingame.big 的 44 項中,43 項壓縮、1 項(alib_ingamelogos.fel)是純文字。 所以工具要逐項判斷,不能假設「這個 .big 裡全都壓縮」。

我們改過這一段

這裡原本舉的例子是「schedule.big 的 9 個賽程表中,6 個壓縮、3 個是純文字」。 那是本站測試機的狀況 —— 拿剛安裝好的原版一驗: 原版那 9 個項目全部都是壓縮的,一個純文字都沒有。

差別是本站這台的 schedule.big 被工具改寫過。那 3 個純文字項目, 正好是換賽程年份那一課會動到的 mlb162_1/2/3.dat

順帶學到一件事:原本壓縮的項目,被工具改寫之後可能變成未壓縮存回去。 規則本身沒錯,錯的是拿一個被改過的檔當例子。

檔頭

byte[0]  旗標   & 0x01 決定檔頭長度
byte[1]  0xFB   辨識碼

旗標 & 0x01 == 0  →  檔頭 5 bytes:byte[2..4] 是解壓後大小(3 bytes,big-endian)
旗標 & 0x01 == 1  →  檔頭 10 bytes:壓縮後大小 + 解壓後大小(各 4 bytes,big-endian)

遊戲裡絕大多數是前者,所以你最常看到的開頭就是 10 FB 加 3 個位元組的大小。 實際上本站在兩份安裝上量到的是一個 10 bytes 的檔頭都沒有: 剛安裝好的原版 14,816 個壓縮項目、本站測試機 36,620 個,第一個位元組全部0x10。 所以上面那一行 10 bytes 的寫法,本站沒有樣本可以驗 —— 它不是本站量到的。

四種指令

解壓就是一直讀指令、往輸出區塞資料。指令用第一個位元組的值域來區分:

byte0 範圍指令指令長度複製長度/資料量回看距離
< 0x80短距離複製2 bytes3 – 101 – 0x400
0x80 – 0xBF中距離複製3 bytes4 – 671 – 0x4000
0xC0 – 0xDF長距離複製4 bytes5 – 10281 – 0x20000
0xE0 – 0xFB純資料1 byte4 – 112(每 4 遞增)
≥ 0xFC結束1 byte0 – 3(尾端資料)

後兩欄只有複製指令兩欄都有值。純資料結束不往回看, 它們那一格填的是一次帶出多少位元組,不是距離 —— 對照下面純資料指令的算式 n = ((byte0 & 0x1F) << 2) + 4,byte0 落在 0xE00xFB 時剛好是 4 到 112、每 4 遞增。

每種複製指令都同時可以帶 0 到 3 個「順便寫出去」的位元組。實際算式:

# 2-byte 指令
n      = byte0 & 0x03
length = ((byte0 & 0x1C) >> 2) + 3
offset = ((byte0 & 0x60) << 3) + byte1 + 1

# 3-byte 指令
n      = (byte1 >> 6) & 0x03
length = (byte0 & 0x3F) + 4
offset = ((byte1 & 0x3F) << 8) + byte2 + 1

# 4-byte 指令
n      = byte0 & 0x03
length = ((byte0 & 0x0C) << 6) + byte3 + 5
offset = ((byte0 & 0x10) << 12) + (byte1 << 8) + byte2 + 1

# 純資料指令
n      = ((byte0 & 0x1F) << 2) + 4

「回看距離」的意思是:往已經解出來的資料倒退 N 個位元組,從那裡複製過來。 這也是為什麼解壓一定要從頭循序做,不能跳著解。

壓回去的技巧:純資料編碼

要把改好的內容壓回 RefPack,直覺是得寫一個壓縮器 —— 找重複字串、選最佳指令。 那很慢也容易寫錯。

但格式允許你完全不做匹配搜尋

純資料指令(0xE0 系列)可以表達任意內容。 整份資料全部用純資料指令輸出,格式一樣合法,遊戲照樣讀得懂。

def qfs_compress_literal(data):
    n = len(data)
    out = bytearray([0x10, 0xFB, (n >> 16) & 0xFF, (n >> 8) & 0xFF, n & 0xFF])
    tail = n % 4              # 0~3,交給結束指令帶走
    body = n - tail
    pos = 0
    while pos < body:
        chunk = min(112, body - pos)          # 必為 4 的倍數,上限 112
        out.append(0xE0 | ((chunk - 4) // 4))  # 0xE0~0xFB,不會撞到 0xFC
        out += data[pos:pos + chunk]
        pos += chunk
    out.append(0xFC | tail)                    # 結束標記
    out += data[pos:]
    return bytes(out)

代價與效益

做法47 個檔耗時
(本站測試機的 ingame.big,原版那顆是 44 項)
檔案大小(單例)風險
完整壓縮器(貪婪匹配)> 120 秒11.6 KB壓縮器寫錯就毀檔
純資料編碼0.11 秒85 KB幾乎沒有,邏輯只有十行

換來的檔案大一些,但對本站測試機那顆 2.5 MB 的封裝檔來說只多 3%,而且速度差了一千倍。 本站所有教學腳本都用這個做法。

驗證方式:272 種不同長度的資料各壓一次,逐一檢查每個控制位元組是否落在規格允許的值域, 再用另一套獨立寫的解壓器解回來比對 —— 全部通過。

寫回封裝檔時仍要遵守 append 鐵律

壓好之後別忘了:接到 .big 檔尾、只改目錄與檔頭, 不要整包重新打包。原因見 BIGF 格式頁

實測案例

⚠️ 下面第一列的 fes_ingameinfobar.fel,以及上面「怎麼辨識」那段 10 FB 01 48 D7 E5(就是它的前六個位元組),都取自本站測試機ingame.big剛安裝好的原版沒有這個項目 —— 它是社群模組在 44 項之外多加的三個之一(另外兩個是 fes_ingameboxscore2.felfes_ingamepauseinfo1.fel)。a140_1.dat 那一列同理: 本站測試機是 13,138 解成 57,304,原版是 11,582 解成 54,581。 mini1_en.ffnmlbspr_1.dat 兩列則是兩份安裝上逐位元組相同的。

檔案壓縮後解壓後比例
fes_ingameinfobar.fel11,61784,1837.2×
mini1_en.ffn(字型)12,329133,21610.8×
a140_1.dat(賽程)13,13857,3044.4×
mlbspr_1.dat(春訓)2,2728,7493.9×

📌 重點整理

接下來