Data 解析 Doom 的 WAD 文件

做者:Terhechte,原文連接,原文日期:2016/07/15
譯者:BigbigChai;校對:way;定稿:千葉知風git

Swift 3 : 從 NSData 到 Data 的轉變

Swift 3 帶來了許多大大小小的變化。其中一個是爲常見的 Foundation 引用類型(例如將 NSData 封裝成 Data ,將 NSDate 封裝成 Date)添加值類型的封裝。這些新類型除了改變了內存行爲和名字之外,在方法上也與對應的引用類型有所區別 1。 從更換新方法名這類小改動,到徹底去掉某一功能這種大改動,咱們須要一些時間去適應這些新的值類型。本文會重點介紹做爲值類型的 Data 是如何封裝 NSData 的。github

不只如此,在學習完基礎知識以後,咱們還會寫一個簡單的示例應用。這個應用會讀取和解析一個 Doom 毀滅戰士的 WAD 文件 2小程序

基本區別

對於 NSData,其中一個最多見的使用場景就是調用如下方法加載和寫入數據:swift

func writeToURL(_ url: NSURL, atomically atomically: Bool) -> Bool
func writeToURL(_ url: NSURL, options writeOptionsMask: NSDataWritingOptions) throws
// ... (implementations for file: String instead of NSURL)
init?(contentsOfURL url: NSURL)
init(contentsOfURL url: NSURL, options readOptionsMask: NSDataReadingOptions) throws
// ... (implementations for file: String instead of NSURL)

基本的使用方法並無什麼改動。新的 Data 類型提供瞭如下方法:數組

init(contentsOf: URL, options: ReadingOptions)
func write(to: URL, options: WritingOptions)

留意到 Data 簡化了從文件讀寫數據的方法,本來 NSData 提供了多種不一樣的方法,如今只精簡到兩個方法。安全

比較一下 NSDataData 的方法,能夠發現另外一個變化。NSData 提供了三十個方法和屬性,而 Data 提供了一百三十個。Swift 強大的協議擴展能夠輕易地解釋這個巨大的差別。Data 從如下協議裏得到了許多方法:數據結構

  • CustomStringConvertible閉包

  • Equatableapp

  • Hashabledom

  • MutableCollection

  • RandomAccessCollection

  • RangeReplaceableCollection

  • ReferenceConvertible

這給 Data 提供了許多 NSData 不具有的功能。這裏列出部分例子:

func distance(from: Int, to: Int)
func dropFirst(Int)
func dropLast(Int)
func filter((UInt8) -> Bool)
func flatMap<ElementOfResult>((UInt8) -> ElementOfResult?)
func forEach((UInt8) -> Void)
func index(Int, offsetBy: Int, limitedBy: Int)
func map<T>((UInt8) -> T)
func max()
func min()
func partition()
func prefix(Int)
func reversed()
func sort()
func sorted()
func split(separator: UInt8, maxSplits: Int, omittingEmptySubsequences: Bool)
func reduce<Result>(Result, (partialResult: Result, UInt8) -> Result)

如你所見,許多函數式方法,例如 mapping 和 filtering 如今均可以操做 Data 類型的字節內容了。我認爲這是相對 NSData 的一大進步。優點在於,如今能夠輕鬆地使用下標以及對數據內容進行比較了。

var data = Data(bytes: [0x00, 0x01, 0x02, 0x03])  
print(data[2]) // 2
data[2] = 0x09
print (data == Data(bytes: [0x00, 0x01, 0x09, 0x03])) // true

Data 還提供了一些新的初始化方法專門用於處理 Swift 裏常見的數據類型:

init(bytes: Array<UInt8>)
init<SourceType>(buffer: UnsafeMutableBufferPointer<SourceType>)
init(repeating: UInt8, count: Int)

獲取字節

若是你使用 Data 與底層代碼(例如 C庫)交互,你會發現另外一個明顯的區別:Data 缺乏了 NSDatagetBytes 方法:

// NSData
func getBytes(_ buffer: UnsafeMutablePointer<Void>, length length: Int)

getBytes 方法有許多不一樣的應用場景。其中最多見的是,當你須要解析一個文件並按字節讀取並存儲到數據類型/變量裏。例如說,你想讀取一個包含項目列表的二進制文件。這個文件通過編碼,而編碼方式以下:

數據類型 大小 功能
Char 4 頭部 (ABCD)
UInt32 4 數據開始
UInt32 4 數量

該文件包含了一個四字節字符串 ABCD 標籤,用來表示正確的文件類型(作校驗)。接着的四字節定義了實際數據(例如頭部的結束和項目的開始),頭部最後的四字節定義了該文件存儲項目的數量。

NSData 解析這段數據很是簡單:

let data = ...
var length: UInt32 = 0
var start: UInt32 = 0
data.getBytes(&start, range: NSRange(location: 4, length: 4))
data.getBytes(&length, range: NSRange(location: 8, length: 4))

如此將返回正確結果3。若是數據不包含 C 字符串,方法會更簡單。你能夠直接用正確的字段定義一個 結構體,而後把字節讀到結構體裏:

數據類型 大小 功能
UInt32 4 數據開始
UInt32 4 數量
let data = ...
struct Header { 
    let start: UInt32
    let length: UInt32
}
var header = Header(start: 0, length: 0)
data.getBytes(&header, range: NSRange(location: 0, length: 8))

Data 中 getBytes 的替代方案

不過 Data 裏 getBytes 這個功能再也不可用,轉而提供了一個新方法做替代:

// 從數據裏得到字節
func withUnsafeBytes<ResultType, ContentType>((UnsafePointer<ContentType>) -> ResultType)

經過這個方法,咱們能夠從閉包中直接讀取數據的字節內容。來看一個簡單的例子:

let data = Data(bytes: [0x01, 0x02, 0x03])
data.withUnsafeBytes { (pointer: UnsafePointer<UInt8>) -> Void in
    print(pointer)
    print(pointer.pointee)
}
// 打印
// : 0x00007f8dcb77cc50
// : 1

好了,如今有一個指向數據的 unsafe UInt8 指針,那要怎樣利用起來呢?首先,咱們須要一個不一樣的數據類型,而後必定要肯定該數據的類型。咱們知道這段數據包含一個 Int32 類型,那該如何正確地解碼呢?

既然已經有了一個 unsafe pointer(UInt8 類型),那麼就可以輕鬆地轉換成目標類型 unsafe pointer。UnsafePointer 有一個 pointee 屬性,能夠返回指針所指向數據的正確類型:

let data = Data(bytes: [0x00, 0x01, 0x00, 0x00])
let result = data.withUnsafeBytes { (pointer: UnsafePointer<Int32>) -> Int32 in
    return pointer.pointee
}
print(result)
//: 256

如你所見,咱們建立了一個字節的 Data 實例,經過在閉包裏定義 UnsafePointer<Int32>,返回 Int32 類型的數據。能夠把代碼寫得再精簡一點,由於編譯器可以根據上下文推斷結果類型:

let result: Int32 = data.withUnsafeBytes { $0.pointee }

數據的生命週期

使用 withUnsafeBytes 時,指針(你所訪問的)的生命週期是一個很重要的考慮因素(除了整個操做都是不安全的以外)。指針的生命週期受制於閉包的生命週期。正如文檔所說:

留意:字節指針參數不該該被存儲,或者在所調用閉包的生命週期之外被使用。

泛型解決方案

如今,咱們已經能夠讀取原始字節數據,並把它們轉換成正確的類型了。接下來建立一個通用的方法來更輕鬆地執行操做,而不用額外地關心語法。 另外,咱們暫時還沒法針對數據的子序列執行操做,而只能對整個 Data 實例執行操做。 泛型的解決方法大概是這個樣子的:

extension Data {
    func scanValue<T>(start: Int, length: Int) -> T {
        return self.subdata(in: start..<start+length).withUnsafeBytes { $0.pointee }
    }
}
let data = Data(bytes: [0x01, 0x02, 0x01, 0x02])
let a: Int16 = data.scanValue(start: 0, length: 1)
print(a)
// : 1

與以前的代碼相比,存在兩個顯著的不一樣點:

  • 咱們使用了 subdata 把掃描的字節限定於所需的特定區域。

  • 咱們使用了泛型來支持提取不一樣的數據類型。

數據轉換

另外一方面,從現有的變量內容裏獲得 Data 緩衝, 雖然與下面的 Doom 的例子不相關,可是很是容易實現,(所以也寫在這裏啦)

var variable = 256
let data = Data(buffer: UnsafeBufferPointer(start: &variable, count: 1))
print(data) // : <00010000 00000000>

解析 Doom WAD 文件

我小時候很是熱愛 Doom(毀滅戰士)這個遊戲。也玩到了很高的等級,並修改 WAD 文件加入了新的精靈,紋理等。所以當我想給解析二進制文件找一個合適(和簡單)的例子時,就想起了 WAD 文件的設計。由於它十分直觀且容易實現。因而我寫了一個簡單的小程序,用於讀取 WAD 文件,而後列出全部存儲地板的紋理名稱 4

我把源代碼 放在了 GitHub 
如下兩個文件解釋了Doom WAD 文件的設計。

可是對於這個簡單的示例,只須要了解部分的文件格式就夠了。
首先,每一個 WAD 文件都有頭文件:

數據類型 大小 功能
Char 4 字符串 IWAD 或者 PWAD
Int32 4 WAD 中區塊的數目
Int32 4 指向目錄位置的指針

開頭的 4 字節用來肯定文件格式。 IWAD 代表是官方的 Doom WAD 文件,PWAD 代表是在運行時補充內容到主要 WAD 文件的補丁文件。咱們的應用只會讀取 IWAD 文件。接着的 4 字節肯定了 WAD 文件中 區塊(lump) 的數目。 區塊(Lump)是與 Doom 引擎合做的個體項目,例如紋理材質、精靈幀(Sprite-Frames),文字內容,模型,等等。每一個紋理都是不一樣類的區塊。最後的 4 字節定義了目錄的位置。咱們開始解析目錄的時候,會給出相關解釋。首先,讓咱們來解析頭文件。

解析頭文件

讀取 WAD 文件的方法很是簡單:

let data = try Data(contentsOf: wadFileURL, options: .alwaysMapped)

咱們獲取到數據以後,首先須要解析頭文件。這裏屢次使用了以前建立的 scanValuedata`` 擴展。

public func validateWadFile() throws {
    // 一些 Wad 文件定義
    let wadMaxSize = 12, wadLumpsStart = 4, wadDirectoryStart = 8, wadDefSize = 4
    // WAD 文件永遠以 12 字節的頭文件開始。
    guard data.count >= wadMaxSize else { throw WadReaderError.invalidWadFile(reason: "File is too small") }

    // 它包含了三個值:

    // ASCII 字符 "IWAD" 或 "PWAD" 定義了 WAD 是 IWAD 仍是 PWAD。
    let validStart = "IWAD".data(using: String.Encoding.ascii)!
    guard data.subdata(in: 0..<wadDefSize) == validStart else
    { throw WadReaderError.invalidWadFile(reason: "Not an IWAD") }

    // 一個聲明瞭 WAD 中區塊數目的整數。
    let lumpsInteger: Int32 = data.scanValue(start: wadLumpsStart, length: wadDefSize)

    // 一個整數,含有指向目錄地址的指針。
    let directoryInteger: Int32 = data.scanValue(start: wadDirectoryStart, length: wadDefSize)

    guard lumpsInteger > 0 && directoryInteger > Int32(wadMaxSize)
    else {
        throw WadReaderError.invalidWadFile(reason: "Empty Wad File")
    }
}

你能夠在 GitHub 找到其餘的類型(例如 WadReaderError enum)。下一步就是解析目錄來獲取每一個區塊的地址和大小。

解析目錄

目錄與區塊的名字、包含的數據相關聯。它包括了一系列的項目,每一個項目的長度爲 16 字節。目錄的長度取決於 WAD 頭文件裏給出的數字。

每一個 16 字節的項目按照如下的格式:

數據類型 大小 功能
Int32 4 區塊數據在文件中的開始
Int32 4 區塊的字節大小
Char 4 定義了區塊名字的 ASCII 字符串

名字的字符定義得比較複雜。文檔是這麼說的:

使用 ASCII 字符串定義區塊的名字。區塊的名字只能使用 A-Z(大寫),0-9,[ ] - _(Arch-Vile 精靈除外,它們使用 \)。若是這串字符小於 8 字節長度,那麼餘下字節要被 null 填滿。

留意最後一句話。在 C 語言裏,字符串由空字符(0)結束。這向系統代表了該字符串的內存到這裏結束。Doom 用可選的空字符來節約存儲空間。當字符串小於 8 字節,它會包含一個空字符。若是它達到最大容許長度( 8 字節),那麼字符串以最後一個字節結束,而非由空字符結束。

  0 1 2 3 4 5 6 7  
I M P 0 0 0 0 0 #
F L O O R 4 _ 5 #

看看上面的表格, 短名字會在字符串最後補空字符(位置 3)。長名字則沒有空字符,而是以 FLOOR4_5 的最後一個字符 5 做爲結束。#代表了下一個項目/片斷在內存中的開始。

在咱們嘗試支持區塊的名字字符格式以前,首先處理一下簡單的部分。那就是讀取開頭和大小。

在開始以前,咱們應該定義一個數據結構,用於保存從目錄裏讀取的內容:

public struct Lump {
    public let filepos: Int32
    public let size: Int32
    public let name: String
}

而後,從完整的數據實例裏取出數據片斷,這是這些數據構成咱們的目錄。

// 定義一個目錄項的默認大小。
let wadDirectoryEntrySize = 16
// 從完整數據裏提取目錄片斷。
let directory = data.subdata(in: Int(directoryLocation)..<(Int(directoryLocation) + Int(numberOfLumps) * wadDirectoryEntrySize))

接着,咱們以每段 16 字節的長度在 Data 中迭代。 Swift 的 stride 方法可以很好地實現這個功能:

for currentIndex in stride(from: 0, to: directory.count, by: wadDirectoryEntrySize) {
    let currentDirectoryEntry = directory.subdata(in: currentIndex..<currentIndex+wadDirectoryEntrySize)

    // 一個整數代表區塊數據的起始在文件中的位置。
    let lumpStart: Int32 = currentDirectoryEntry.scanValue(start: 0, length: 4)

    // 一個表示了區塊字節大小的整數。
    let lumpSize: Int32 = currentDirectoryEntry.scanValue(start: 4, length: 4)
    ...
}

簡單的部分到此結束,下面咱們要開始進入秋名山飆車了。

解析 C 字符串

要知道對於每一個區塊的名字,每當遇到空的結束字符或者達到 8 字節的時候,咱們都要中止向 Swift 字符串的寫入。首要任務是利用相關數據建立一個數據片斷。

let nameData = currentDirectoryEntry.subdata(in: 8..<16)

Swift 給 C 字符串提供了很好的互操做性。這意味着須要建立一個字符串的時候,咱們只須要把數據交給 String 的初始化方法就好了:

let lumpName = String(data: nameData, encoding: String.Encoding.ascii)

這個方法能夠執行,可是結果並不正確。由於它忽略了空結束符,因此即便是短名字,也會跟長名字同樣轉換成 8 字節的字符串。例如,名字爲 IMP 的區塊會變成 IMP00000。可是因爲 String(data:encoding:) 並不知道 Doom 把剩下的 5 字節都用空字符填滿了,而是根據  nameData 建立了一個完整 8 字節的字符串。

若是咱們想要支持空字符, Swift 提供了一個 cString 初始化方法,用來讀取包含空結束符的有效 cString:

// 根據所給的 C 數組建立字符串
// 根據所給的編碼方式編碼
init?(cString: UnsafePointer<CChar>, encoding enc: String.Encoding)

留意這裏的參數不須要傳入 data 實例,而是要求一個指向 CChars 的 unsafePointer。咱們已經熟悉這個方法了,來寫一下:

let lumpName2 = nameData.withUnsafeBytes({ (pointer: UnsafePointer<UInt8>) -> String? in
    return String(cString: UnsafePointer<CChar>(pointer), encoding: String.Encoding.ascii)
})

以上方法依然不能獲得咱們想要的結果。在 Doom 的名字長度小於 8 字符的狀況下,這段代碼都能完美運行。可是隻要某個名字長度達到 8 字節而沒有一個空結束符時,這會繼續讀取(變成一個 16 字節片斷),直到找到下一個有效的空結束符。 這就帶來一些不肯定長度的長字符串。

這個邏輯是 Doom 自定義的,所以咱們須要本身來實現相應的代碼。Data 支持 Swift 的集合和序列操做,所以咱們能夠直接用 reduce 來解決。

let lumpName3Bytes = try nameData.reduce([UInt8](), { (a: [UInt8], b: UInt8) throws -> [UInt8] in
    guard b > 0 else { return a }
    guard a.count <= 8 else { return a }
    return a + [b]
})
guard let lumpName3 = String(bytes: lumpName3Bytes, encoding: String.Encoding.ascii)
    else {
    throw WadReaderError.invalidLup(reason: "Could not decode lump name for bytes \(lumpName3Bytes)")
}

這段代碼把數據以 UInt8 字節 reduce,並檢查數據是否含有提早的空結束符。一切工做正常,雖然數據須要進行幾回抽象,執行速度並非很快。

不過若是咱們能以 Doom 引擎相似的方法來解決的話,效果會更好。Doom 僅移動了 char* 的指針,並根據字符是否爲空結束符判斷是否須要提早跳出。Doom 是用 C 語言寫的,所以它能在裸指針層面上迭代。

那麼咱們要怎樣在 Swift 裏實現這個邏輯呢?事實上,能夠再次藉助 withUnsafeBytes 實現相似的效果。來看看代碼:

let finalLumpName = nameData.withUnsafeBytes({ (pointer: UnsafePointer<CChar>) -> String? in
    var localPointer = pointer
    for _ in 0..<8 {
    guard localPointer.pointee != CChar(0) else { break }
    localPointer = localPointer.successor()
    }
    let position = pointer.distance(to: localPointer)
    return String(data: nameData.subdata(in: 0..<position),
          encoding: String.Encoding.ascii)
})
guard let lumpName4 = finalLumpName else {
    throw WadReaderError.invalidLup(reason: "Could not decode lump name for bytes \(lumpName3Bytes)")
}

withUnsafeBytes 的用法與以前類似,咱們接受一個指向原始內存的指針。 指針 是一個 let 常數,可是因爲咱們須要對它作修改,所以咱們在第一行建立了一個可變的拷貝5

接着,開始咱們的主要工做。從 0 到 8 循環,每次循環都檢測指針指向的字符(pointee)是否爲空結束符(CChar(0))。是空結束符的話就代表提早找到了空結束符,須要跳出循環。不然將 localPointer 重載爲下一位,即就是,當前指針內存中的下一個位置。這樣,咱們就能逐字節地讀取內存中的全部內容了。

完成以後 ,就計算一下咱們原始指針本地指針的距離。若是在找到空結束符以前咱們僅前移了三次,那麼兩個指針以前的距離爲 3。最後,這個距離能讓咱們經過實際 C 字符串的子數據建立一個新的 String 實例。

最後用獲得的數據建立新的 區塊 結構體:

lumps.append(Lump(filepos: lumpStart, size: lumpSize, name: lumpName4))

若是你觀察源代碼,會發現 F_STARTF_END 這種顯著的引用。對於特殊的 區塊區域 ,Doom 使用特殊名稱的空區塊標記了區域的開頭和結尾。F_START / F_END 圍起了全部地板紋理的區塊。在本教程中,咱們將忽略這額外的一步。

應用最終的截圖:

我知道這看起來並不酷炫。以後可能會計劃在博客裏寫寫如何展現那些紋理。

橋接 NSData

我發現新的 DataNSData 使用起來更加方便。然而,若是你須要 NSData 或者 getBytes 方法的話,這有一個簡單的方法能把 Data 轉換成 NSData。Swift 文檔是這麼寫的:

Data 具備「寫時拷貝」能力,也能與 Objective-C 的 NSData 類型橋接。 對於 NSData 的自定義子類,你可使用 myData as Data 把它的一個實例轉換成結構體 Data 。

// 建立一個 Data 結構體
let aDataStruct = Data()
// 得到底層的引用類型 NSData
let aDataReference = aDataStruct as NSData

不管什麼時候,若是你以爲 Data 類型難以知足你的需求,都能輕鬆地回到 NSData 類型使用你熟悉的方法。不過總而言之你仍是應該儘量地使用新的 Data 類型(除非你須要引用類型的語法)。

1: 有些類型(例如 Date) 並非包裹類型,而是全新的實現。

2: Doom1,Doom2,Hexen,Heretic,還有 Ultimate Doom。雖然我只在 Doom1 Shareware 驗證過。</sup

3: 留意,咱們並無驗證最開頭的 4 個字節,確保這的確是 ABCD 文件。可是要添加這個驗證也很簡單。</sup

4: 其實我也想展現 texture 可是不夠時間去實現。</sup

5: Swift 3 再也不在閉包和函數體裏支持有用的 var 標註。</sup

本文由 SwiftGG 翻譯組翻譯,已經得到做者翻譯受權,最新文章請訪問 http://swift.gg

相關文章
相關標籤/搜索