Try   HackMD

FF14 漢化筆記

此程式現於Github開源。

  1. PatchTool 修改版
  2. SaintCoinach 修改版

1 背景知識

  1. FFXIV資料格式
  2. Java
  3. C#

2 FFXIVChnTextPatch

  1. 從Github下載FFXIVChnTextPatch
  2. 安裝Java SDK(我是用Java OpenJDK 16.0.1)
  3. 安裝Java的IDE(我是用Eclipse 2021-06版本)
  4. 安裝Dependencies,兩種選擇:
    1. 專案瀏覽器中右鍵點擊資料夾,內容>Java建置路徑,在類別路徑選擇新增外部jar。所需要的jar列表可以在gradle找到。
    2. 也可以考慮將專案轉換成maven project,可以在pom.xml中設定dependencies。

2.1 Java Decompiler

Java Decompiler is used to get the source codes of latest version.

我們將decompile的檔案拆包,覆蓋原本的檔案(FFXIVChnTextPatch\src\main\java\)。

清除專案重新build,發現無法執行。用和前面一樣的方法新增commons-logging-1.2這個jar,運行成功。

2.2 修改程式碼

在做任何修改之前,實際先測試build一遍,發現會跳以下錯誤

java.nio.BufferUnderflowException
	at java.base/java.nio.HeapByteBuffer.get(HeapByteBuffer.java:183)
	at java.base/java.nio.ByteBuffer.get(ByteBuffer.java:822)
	at name.yumao.ffxiv.chn.model.EXDFEntry.getString(EXDFEntry.java:83)
	at name.yumao.ffxiv.chn.replace.ReplaceEXDF.replace(ReplaceEXDF.java:178)
	at name.yumao.ffxiv.chn.thread.ReplaceThread.run(ReplaceThread.java:42)
	at java.base/java.lang.Thread.run(Thread.java:831)

這是因為我們反編譯的檔案少了一串括號。修改EXDFEntry.java:

// nullTermPos = buffer.position() - datasetChunkSize + stringOffset; nullTermPos = buffer.position() - (datasetChunkSize + stringOffset);

接著又遇到這個問題:

Now File : EXD/AchievementHideCondition.EXH
Replace File : AchievementHideCondition
java.lang.NullPointerException: Cannot read the array length because "array" is null
	at java.base/java.nio.ByteBuffer.wrap(ByteBuffer.java:435)
	at name.yumao.ffxiv.chn.model.EXDFFile.loadEXDF(EXDFFile.java:32)
	at name.yumao.ffxiv.chn.model.EXDFFile.<init>(EXDFFile.java:18)
	at name.yumao.ffxiv.chn.replace.ReplaceEXDF.replace(ReplaceEXDF.java:139)
	at name.yumao.ffxiv.chn.thread.ReplaceThread.run(ReplaceThread.java:38)
	at java.base/java.lang.Thread.run(Thread.java:831)

修改ReplaceEXDF.java,新增continue;

try { exdFileJA = extractFile(this.pathToIndexSE, exdIndexFileJA.getOffset()); } catch (Exception jaEXDFileException) { continue; }

這樣就可以正常運行了。
類似的結構在EXDFUtil.java的210行、272行等地方也有出現。如果想防止意外,也可以都做修改。建議搜尋catch (Exception jaEXDFileException)

2.3 修改:吃國際版漢化過的檔案

參考這篇巴哈小屋文章

為了方便能同時吃未漢化和漢化過的檔案,我們將抽取處也設定進config裡面。
要修改以下檔案

  • ConfigApplicationPanel.java
  • EXDFUtil.java
  • ReplaceEXDF.java

2.4 修改:指針

修正指針不會跑的問題,如下。

ReplaceEXDF.java

// edited // this.percentPanel.percentShow(++fileCount / this.fileList.size()); percentPanel.percentShow((double)(++fileCount) / (double)fileList.size());

2.5 移除提摩院更新

FFXIVPatchMain.java移除以下部分

PercentPanel percentPanel = new PercentPanel("提莫苑|获取资源"); try { String index = Request.Get("http://ffxiv.chn.teemo.name/index").connectTimeout(1000).socketTimeout(1000).execute().returnContent().asString(); // System.out.println("index: " + index); Type listType = (new TypeToken<ArrayList<TeemoUpdateVo>>() { }).getType(); updates = (List)(new Gson()).fromJson(index, listType); } catch (Exception exception) { System.out.println("Exception caught in Request get!"); } finally { percentPanel.dispose(); }

修改以下檔案中和update有關的部分:

  • ConfigApplicationPanel.java
  • ReplaceEXDF.java
  • ReplaceThread.java
  • RollbackThread.java
  • TextPatchPanel.java

將與TeemoUpdateVoupdate有關的行列刪掉或註解掉,然後依序檢查跳錯誤的地方,最後將import去除。

另外,ConfigApplicationPanel.java中的資源版本項也可以一併註解掉。

/* private JLabel transModeLable = new JLabel("资源版本"); private JComboBox<String> transModeVal; */

2.6 EXD/QUEST.EXH

任務名稱還是原文沒有翻譯。推測是EXD/QUEST.EXH沒有被正確處理。

進入EXDFUtil.java檢查,最後鎖定問題在於public HashMap<String, byte[]> exQuestCN(HashMap<String, byte[]> exMap)沒有將任何東西塞進questMap。而會發生這樣的事情是因為沒有讀到任何的「KEY」,例如SubFst010_00001這樣一串符號。

透過Godbert,我們發現key的Offset從0x968變成了0x96C。因此,我們修改第二個if處的offset條件:

if (exdfDatasetSE.type == 0x0 && exdfDatasetSE.offset == 0x96C /* it was 0x968 */) { key = new String(exdfEntryJA.getString(exdfDatasetSE.offset), "UTF-8"); // System.out.println("\t\t\tGet Key! " + String.valueOf(key)); }
if (exdfDatasetSE.type == 0x0 && exdfDatasetSE.offset == 0x96C /* it was 0x968 */) { String key = new String(exdfEntryJA.getString(exdfDatasetSE.offset), "UTF-8"); if (sourceMap.get(key) != null) exMap.put(("EXD/Quest_".toLowerCase() + String.valueOf(listEntryIndex) + "_1").toLowerCase(), sourceMap.get(key)); break; }

2.7 讀取CSV

讀取CSV的功能我使用了Univocity Parser,請記得在ReplaceEXDF.java加上import。

ConfigApplicationPanel.java

// added // 因為新增了這一段,以下各物件的setBounds的第二項都要增加30(往下移)。 // 為了因應CSV,新增CSV選項。 this.fLangLable.setBounds(30, 70, 100, 25); this.fLangLable.setFont(new Font("Microsoft Yahei", 1, 13)); this.fLangLable.setForeground(new Color(110, 110, 110)); add(this.fLangLable, 0); this.fLangLableVal = new JComboBox<>(); this.fLangLableVal.addItem("CSV"); this.fLangLableVal.addItem("日文"); this.fLangLableVal.addItem("英文"); this.fLangLableVal.addItem("德文"); this.fLangLableVal.addItem("法文"); this.fLangLableVal.addItem("簡體中文"); this.fLangLableVal.setBounds(100, 70, 160, 23); this.fLangLableVal.setFont(new Font("Microsoft Yahei", 1, 13)); this.fLangLableVal.setForeground(new Color(110, 110, 110)); this.fLangLableVal.setOpaque(false); this.fLangLableVal.setFocusable(false); add(this.fLangLableVal, 0);

Language.java

public enum Language { CHS("簡體中文", "CHS", "chs", "5"), CHT("正體中文", "CHT", "cht", "5"), CSV("CSV", "CSV", "csv", "6"), JA("日文", "JA", "ja", "0"), EN("英文", "EN", "en-gb", "1"), DE("德文", "DE", "de", "2"), FR("法文", "FR", "fr", "3");

ReplaceEXDF.java 在以下位置新增CSV用的內容。

7/21修改:確定FileReader編碼以避免後續輸出exe時出現編碼問題。

if ((exhSE.getLangs()).length > 0) { // added for CSV HashMap<Integer, Integer> offsetMap = new HashMap<>(); // ArrayList<List<String>> dataList = new ArrayList<List<String>>(); HashMap<Integer, String[]> csvDataMap = new HashMap<>(); if (this.csv) { try { CsvParserSettings csvSettings = new CsvParserSettings(); csvSettings.setMaxCharsPerColumn(-1); csvSettings.setMaxColumns(4096); CsvParser csvParser = new CsvParser(csvSettings); String csvPath = "resource" + File.separator + "rawexd" + File.separator + replaceFile.substring(4, replaceFile.indexOf(".")) + ".csv"; if (new File(csvPath).exists()) { List<String[]> allRows = csvParser.parseAll(new FileReader(csvPath, StandardCharsets.UTF_8)); for (int i = 1; i < allRows.get(1).length; i++) { offsetMap.put(Integer.valueOf((allRows.get(1))[i]), i - 1); } int rowNumber = allRows.size(); for (int i = 3; i < rowNumber; i++) { csvDataMap.put(Integer.valueOf((allRows.get(i))[0]), Arrays.copyOfRange(allRows.get(i), 1, allRows.get(i).length)); } } else { System.out.println("\t\tCSV file not exists! " + csvPath); continue; } } catch (Exception csvFileIndexValueException) { System.out.println("\t\tCSV Exception. " + csvFileIndexValueException.getMessage()); continue; } }
// 更新文本內容 // EXD/warp/WarpInnUldah.EXH -> exd/warp/warpinnuldah_xxxxx_xxxxx String transKey = replaceFile.substring(0, replaceFile.lastIndexOf(".")).toLowerCase() + "_" + String.valueOf(listEntryIndex) + "_" + String.valueOf(stringCount); if (this.csv) { // added CSV mode // need a name like quest or quest/000/ClsHrv001_00003 (replaceFile) // need an offset (exdfDatasetSE.offset) Integer offsetInteger = offsetMap.get(Integer.valueOf(exdfDatasetSE.offset)); String[] rowStrings = csvDataMap.get(listEntryIndex); if (rowStrings != null) { String readString = rowStrings[offsetInteger]; String newString = new String(); boolean isHexString = false; if (readString != null) { for (int i = 0; i < readString.length(); i++) { char currentChar = readString.charAt(i); switch (currentChar) { case '<': { if ((readString.charAt(i+1) == 'h') && (readString.charAt(i+2) == 'e') && (readString.charAt(i+3) == 'x')) { if (isHexString) { throw new Exception("TagInTagException!" + readString); } else { isHexString = true; } } if (newString.length() > 0) { newFFXIVString = ArrayUtil.append(newFFXIVString, newString.getBytes("UTF-8")); newString = ""; } newString += currentChar; break; } case '>': { newString += currentChar; if (isHexString) { newFFXIVString = ArrayUtil.append(newFFXIVString, HexUtils.hexStringToBytes(newString.substring(5, newString.length() - 1))); newString = ""; isHexString = false; } break; } default: { newString += currentChar; } } } if (newString.length() > 0) { newFFXIVString = ArrayUtil.append(newFFXIVString, newString.getBytes("UTF-8")); newString = ""; } } else { System.out.println("\t\tCannot find listEntryIndex " + String.valueOf(listEntryIndex)); newFFXIVString = ArrayUtil.append(newFFXIVString, jaBytes); } } // added end ^ } else if (Config.getConfigResource("transtable") != null && Config.getProperty("transtable", transKey) != null && Config.getProperty("transtable", transKey).length() > 0) {

如果想要,前面也可以作相對應修改,讓我們可以不用在common/text資料夾下放陸版檔案。

boolean cnEXHFileAvailable = true; if (!this.csv) { try { SqPackIndexFile exhIndexFileCN = (SqPackIndexFile)((SqPackIndexFolder)indexCN.get(filePatchCRC)).getFiles().get(exhFileCRC); byte[] exhFileCN = extractFile(this.pathToIndexCN, exhIndexFileCN.getOffset()); exhCN = new EXHFFile(exhFileCN); // 添加對照的StringDataset int cnDatasetPossition = 0; if (datasetStringCount(exhSE.getDatasets()) > 0 && datasetStringCount(exhSE.getDatasets()) == datasetStringCount(exhCN.getDatasets())) for (EXDFDataset datasetSE : exhSE.getDatasets()) { if (datasetSE.type == 0) { while ((exhCN.getDatasets()[cnDatasetPossition]).type != 0) cnDatasetPossition++; datasetMap.put(datasetSE, exhCN.getDatasets()[cnDatasetPossition++]); } } } catch (Exception cnEXHFileException) { cnEXHFileAvailable = false; } } else { cnEXHFileAvailable = false; } if ((exhSE.getLangs()).length > 0) { // added for CSV HashMap<Integer, Integer> offsetMap = new HashMap<>(); // ArrayList<List<String>> dataList = new ArrayList<List<String>>(); HashMap<Integer, String[]> csvDataMap = new HashMap<>(); if (this.csv) { try { CsvParserSettings csvSettings = new CsvParserSettings(); csvSettings.setMaxCharsPerColumn(-1); csvSettings.setMaxColumns(4096); CsvParser csvParser = new CsvParser(csvSettings); String csvPath = "resource" + File.separator + "rawexd" + File.separator + replaceFile.substring(4, replaceFile.indexOf(".")) + ".csv"; if (new File(csvPath).exists()) { List<String[]> allRows = csvParser.parseAll(new FileReader(csvPath)); for (int i = 1; i < allRows.get(1).length; i++) { offsetMap.put(Integer.valueOf((allRows.get(1))[i]), i - 1); } int rowNumber = allRows.size(); for (int i = 3; i < rowNumber; i++) { csvDataMap.put(Integer.valueOf((allRows.get(i))[0]), Arrays.copyOfRange(allRows.get(i), 1, allRows.get(i).length)); } } else { System.out.println("\t\tCSV file not exists! " + csvPath); continue; } } catch (Exception csvFileIndexValueException) { System.out.println("\t\tCSV Exception. " + csvFileIndexValueException.getMessage()); continue; } }

然後是ReplaceThread.java

if ((new File("resource" + File.separator + "text" + File.separator + "0a0000.win32.index")).exists()) { (new ReplaceEXDF(this.resourceFolder + File.separator + "0a0000.win32.index", "resource" + File.separator + "text" + File.separator + "0a0000.win32.index", percentPanel)).replace(); } else if ((new File("resource" + File.separator + "rawexd" + File.separator + "Achievement.csv")).exists()) { (new ReplaceEXDF(this.resourceFolder + File.separator + "0a0000.win32.index", "resource" + File.separator + "rawexd" + File.separator + "Achievement.csv", percentPanel)).replace(); } else { System.out.println("No resource files detected!"); }

2.8 字型問題

在我們可以選擇用已漢化的包作為放進resource/text/的檔案後,發現這樣做的時候字體包會不太一樣,導致某些圖像類的英數字區塊會出現亂碼。

因為ReplaceFont和ReplaceEXDF是處理不同的檔案,目前先用手上的漢化覆蓋檔的000000.win32....來直接覆蓋遊戲檔案,可以解決。

如果手上有覆蓋漢化檔,可以用以下方式解決。

2.8.1 拆包替換

使用FFXIV Explorer查看漢化覆蓋檔的000000.win32.dat0裡面的檔案,然後和提摩院的漢化resource/font資料夾內的檔案做比較。

除了完全一樣的檔案外,部分檔案在FFXIV Explorer無法正確顯示。透過比較binary內容,確定以下檔案對應:

漢化包檔案 覆蓋檔FFXIV Explorer拆包檔案
axis_12.fdt ~a9b7b1a2
axis_14.fdt ~26f74402
axis_18.fdt ~e307a903
axis_36.fdt ~11ffb669
axis_96.fdt ~b064950f
miedingermid_10.fdt ~7b3aa512
miedingermid_12.fdt ~1faf672
miedingermid_14.fdt ~8eba03d2
miedingermid_18.fdt ~4b4aeed3
trumpgothic_184.fdt ~ffa087be

除此之外,以下檔案與原版檔案不同,且漢化包中沒有。我們猜測這些檔案和其他檔案一樣,都是font + number + .fdt的格式,所以手猜猜出了四個,最後一個是用程式窮舉直到找出結果為止。將這些檔案改名後放到resource/font裡面,就可以解決字體問題。

覆蓋檔FFXIV Explorer拆包檔案 嘗試結果
73CDF66F meidinger_40.fdt
B9B3F2B9 miedingermid_36.fdt
D2024114 jupiter_90.fdt
DC44E770 trumpgothic_68.fdt
E1DCA76A jupiter_46.fdt

2.9 指令碼處理

可參考這篇。例如最基礎的換行指令碼(0x02100103)基本的結構是:

02 10 01 03
指令碼開頭 指令碼類型 指令碼長度 指令碼結尾
  • 指令碼類型可以參考SaintCoinach的TagType.cs
  • 指令碼長度n是指接下來的n1的byte屬於指令內,不含指令碼結尾,例如0x01代表11=0
    • 特殊指令碼長度可以參考SaintCoinach的XIVStringDecoder.csprotected static int GetInteger(BinaryReader input, List<byte> lenByte)等函式
    • IntegerType.cs
  • 指令碼中的特殊指令碼(例如If判斷句)會以0xFF開頭,後面接一個長度,格式與上類似。
    • XIVStringDecoder.csprotected INode DecodeExpression(BinaryReader input)
    • DecodeExpressionType.cs
  • 其他關於SaintCoinach的實作和修改列於後面章節。

另一個例子:

02 48 04 F20215 03
指令碼開頭 指令碼類型
(UI前景)
長度
3 bytes
指令內容
UI前景類型
指令碼結尾

更複雜的例子:

020851E4E80201FF25024804F2021E03024904F2021F03e78db5e99abce999a3e7879f02490201030248020103FF25024804F2022003024904F2022103e6b8a1e9b489e999a3e7879f0249020103024802010303

<hex:020851E4E80201FF25><hex:024804F2021E03><hex:024904F2021F03>獵隼陣營<hex:0249020103><hex:0248020103><hex:FF25><hex:024804F2022003><hex:024904F2022103>渡鴉陣營<hex:0249020103><hex:0248020103><hex:03>

switch case:
<hex:020957E802FF10E38199E381B9E381A6E8A1A8E7A4BAFF1FE382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BAFF22E69CAAE382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BA03>
<Switch(IntegerParameter(1))><Case(1)>顯示全部</Case><Case(2)>只顯示已完成</Case><Case(3)>只顯示未完成</Case></Switch>

  • 02 指令開頭
  • 09 指令類型:Switch
  • 57 指令長度
  • FF 指令段落點,下一個byte10是段落長度
  • E38199E381B9E381A6E8A1A8E7A4BA 內文,UTF-8為すべて表示
  • FF1F 前byte為指令段落點,後byte為段落長度
  • E382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BA 內文,UTF-8為コンプリートのみ表示
  • FF22
  • E69CAAE382B3E383B3E38397E383AAE383BCE38388E381AEE381BFE8A1A8E7A4BA 內文,UTF-8為未コンプリートのみ表示
  • 03 指令結尾

3 SaintCoinach

  1. 安裝Visual Studio
  2. 匯入專案
  3. 工具 > Nuget > 套件管理器主控台
  4. 輸入 Update-Package -reinstall

參考資料來源

3.1 讓SaintCoinach可以輸出Offset

為了讓我們可以在csv直接讀到各個column的offset,我們需要讓其輸出offset資訊。我們修改SaintCoinach.Cmd\ExdHelper.cs

public static void SaveAsCsv(Ex.Relational.IRelationalSheet sheet, Language language, string path, bool writeRaw) { using (var s = new StreamWriter(path, false, Encoding.UTF8)) { var indexLine = new StringBuilder("key"); var nameLine = new StringBuilder("#"); var offsetLine = new StringBuilder("offset"); // added var typeLine = new StringBuilder("int32"); var colIndices = new List<int>(); foreach (var col in sheet.Header.Columns) { indexLine.AppendFormat(",{0}", col.Index); nameLine.AppendFormat(",{0}", col.Name); offsetLine.AppendFormat(",{0}", col.Offset); // added typeLine.AppendFormat(",{0}", col.ValueType); colIndices.Add(col.Index); } s.WriteLine(indexLine); s.WriteLine(nameLine); s.WriteLine(offsetLine); // added s.WriteLine(typeLine); ExdHelper.WriteRows(s, sheet, language, colIndices, writeRaw); } }

3.2 修改輸出格式

雖然我們也可以像FFXIVChnTextPatch一樣將包在指令碼裡面的一般文字全部以hex呈現,但既然SaintCoinach做了更精細的分類,那麼沒道理不用。

首先從XivStringDecoder.cs開始。

在SC做decode的過程中,會將代表指令長度的bytes捨去或轉換成int做處理。我們為所有Decoder增加第四個引數lengthByteStr,讓他能以hex的形式輸出這個長度。

public class XivStringDecoder { public delegate INode TagDecoder(BinaryReader input, TagType tag, int length, String lengthByteStr);
#region Constructor public XivStringDecoder() { this.DefaultTagDecoder = DecodeTagDefault; SetDecoder(TagType.Clickable, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 1, int.MaxValue)); // I have no idea. SetDecoder(TagType.Color, DecodeColor); SetDecoder(TagType.CommandIcon, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 1, false)); SetDecoder(TagType.Dash, (i, t, l, h) => new Nodes.StaticString(this.Dash)); SetDecoder(TagType.Emphasis, DecodeGenericSurroundingTag); SetDecoder(TagType.Emphasis2, DecodeGenericSurroundingTag); // TODO: Fixed SetDecoder(TagType.Format, DecodeFormat); SetDecoder(TagType.Gui, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 1, false)); SetDecoder(TagType.Highlight, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); SetDecoder(TagType.If, DecodeIf); SetDecoder(TagType.IfEquals, DecodeIfEquals); // Indent SetDecoder(TagType.InstanceContent, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); SetDecoder(TagType.LineBreak, (i, t, l, h) => new Nodes.StaticString("<hex:02100103>")); SetDecoder(TagType.Sheet, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 2, int.MaxValue)); // Sheet name, Row[, Column[, Parameters]+] SetDecoder(TagType.SheetDe, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.SheetEn, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.SheetFr, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.SheetJa, (i, t, l, h) => DecodeGenericElementWithVariableArguments(i, t, l, h, 3, int.MaxValue)); // Sheet name, Attributive row, Sheet row[, Sheet column[, Attributive index[, Parameters]+] SetDecoder(TagType.Split, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 3, false)); // Input expression, Seperator, Index to use SetDecoder(TagType.Switch, DecodeSwitch); SetDecoder(TagType.Time, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 1, false)); SetDecoder(TagType.TwoDigitValue, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); // Unknowns SetDecoder(TagType.Value, (i, t, l, h) => DecodeGenericElement(i, t, l, h, 0, true)); SetDecoder(TagType.ZeroPaddedValue, DecodeZeroPaddedValue); }

修改主要Decode函式的規則如下。注意第二層Decode()也會被指令碼出現0xFF時呼叫,所以同樣新增lenByte──而且在這種情況下,lenByte會含有0xFF以及代表長度的byte(s)。

#region Decode public XivString Decode(byte[] buffer) { using (var ms = new MemoryStream(buffer)) { using (var r = new BinaryReader(ms, this.Encoding)) return Decode(r, buffer.Length, new List<byte> { } ); } } public XivString Decode(BinaryReader input, int length, List<byte> lenByte) { // check input size if (length < 0) throw new ArgumentOutOfRangeException("length"); // set the end of the input var end = input.BaseStream.Position + length; if (end > input.BaseStream.Length) throw new ArgumentOutOfRangeException("length"); var parts = new List<INode>(); var pendingStatic = new List<byte>(); // Add the bytes representing decode tag (if exist) and length to pendingStatic if (lenByte.Count > 0) { String forText = BitConverter.ToString(lenByte.ToArray()).Replace("-", String.Empty) + ">"; parts.Add(new Nodes.StaticString(forText)); } // while loop until reaching the end while (input.BaseStream.Position < end) { var v = input.ReadByte(); if (v == TagStartMarker) { // What this function does: // If no item in "pending", just return // A list of interface can take any instance of that interface // TargetParts adds an element, which is the string version of "pending" // Finally, remove everything in "pending" // P.S. it modifies the references directly. (list = reference type) AddStatic(pendingStatic, parts); // DecodeTag: byte -> string; added into "parts" parts.Add(DecodeTag(input)); if (input.BaseStream.Position > end) throw new InvalidOperationException(); } else pendingStatic.Add(v); } AddStatic(pendingStatic, parts); // Add <hex: if needed if (lenByte.Count > 0) { parts.Add(new Nodes.StaticString("<hex:")); } return new XivString(parts); }

如果讀入的bytes出現代表指令碼開頭的0x02,就會呼叫以下函式。這個函式會將輸入的binary code轉換成自定義的class INode

private INode DecodeTag(BinaryReader input) { // the first byte means the tag type var tag = (TagType)input.ReadByte(); // edited // the second byte(s) means the length of commnad List<byte> lengthByte = new List<byte> { }; var length = GetInteger(input, lengthByte); String lengthByteStr = BitConverter.ToString(lengthByte.ToArray()).Replace("-", String.Empty); var end = input.BaseStream.Position + length; // System.Diagnostics.Trace.WriteLine(string.Format("{0} @ {1:X}h+{2:X}h", tag, input.BaseStream.Position, length)); TagDecoder decoder = null; // ref and out: // both means to modify the reference; // "out" means it may not be initialized yet, so has to be done in the function. // 這個方法傳回時,如果找到索引鍵,則包含與指定索引鍵相關聯的值,否則為 value 參數類型的預設值。這個參數會以未初始化的狀態傳遞。 _TagDecoders.TryGetValue(tag, out decoder); // "??" operator: return the left side if it is not null; otherwise, return the right side. // If tag in _TagDecoders, decoder will be that decoder; otherwise, it will be DefaultTagDecoder var result = (decoder ?? DefaultTagDecoder)(input, tag, length, lengthByteStr); if (input.BaseStream.Position != end) { // Triggered by two entries in LogMessage as of 3.15. // Looks like a tag has some extra bits, as the end length is a proper TagEndMarker. System.Diagnostics.Debug.WriteLine(string.Format("Position mismatch in XivStringDecoder.DecodeTag. Position {0} != predicted {1}.", input.BaseStream.Position, end)); input.BaseStream.Position = end; } if (input.ReadByte() != TagEndMarker) throw new InvalidDataException(); return result; }

修改各個Tag Decoder的規則:

#region Generic protected INode DecodeTagDefault(BinaryReader input, TagType tag, int length, String lenByte) { return new Nodes.DefaultElement(tag, input.ReadBytes(length), lenByte); } protected INode DecodeExpression(BinaryReader input) { var t = input.ReadByte(); // expressionTypeByte = t; return DecodeExpression(input, (DecodeExpressionType)t); } protected INode DecodeExpression(BinaryReader input, DecodeExpressionType exprType) { var t = (byte)exprType; if (t < 0xD0) { return new Nodes.StaticInteger(t - 1, ((byte)t).ToString("X2")); } if (t < 0xE0) { return new Nodes.TopLevelParameter(t - 1, ((byte)t).ToString("X2")); } List<byte> addByte = new List<byte> { }; addByte.Add((Byte)exprType); switch (exprType) { case DecodeExpressionType.Decode: { var len = GetInteger(input, addByte); // XIVString is also an INode return Decode(input, len, addByte); } case DecodeExpressionType.Byte: { var expr = GetInteger(input, IntegerType.Byte, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int16_MinusOne: { var expr = GetInteger(input, IntegerType.Int16, addByte) - 1; var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int16_1: case DecodeExpressionType.Int16_2: { var expr = GetInteger(input, IntegerType.Int16, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24_MinusOne: { var expr = GetInteger(input, IntegerType.Int24, addByte) - 1; var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24: { var expr = GetInteger(input, IntegerType.Int24, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24_Lsh8: { var expr = GetInteger(input, IntegerType.Int24, addByte) << 8; var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.Int24_SafeZero: { var v16 = input.ReadByte(); var v8 = input.ReadByte(); var v0 = input.ReadByte(); addByte.Add(v16); addByte.Add(v8); addByte.Add(v0); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); int v = 0; if (v16 != byte.MaxValue) v |= v16 << 16; if (v8 != byte.MaxValue) v |= v8 << 8; if (v0 != byte.MaxValue) v |= v0; return new Nodes.StaticInteger(v, lenByte); } case DecodeExpressionType.Int32: { var expr = GetInteger(input, IntegerType.Int32, addByte); var lenByte = BitConverter.ToString(addByte.ToArray()).Replace("-", String.Empty); return new Nodes.StaticInteger(expr, lenByte); } case DecodeExpressionType.GreaterThanOrEqualTo: case DecodeExpressionType.GreaterThan: case DecodeExpressionType.LessThanOrEqualTo: case DecodeExpressionType.LessThan: case DecodeExpressionType.NotEqual: case DecodeExpressionType.Equal: { var left = DecodeExpression(input); var right = DecodeExpression(input); return new Nodes.Comparison(exprType, left, right); } case DecodeExpressionType.IntegerParameter: case DecodeExpressionType.PlayerParameter: case DecodeExpressionType.StringParameter: case DecodeExpressionType.ObjectParameter: { var parameter = DecodeExpression(input); return new Nodes.Parameter(exprType, parameter); } default: throw new NotSupportedException(); } } protected INode DecodeGenericElement(BinaryReader input, TagType tag, int length, String lenByte, int argCount, bool hasContent) { if (length == 0) { return new Nodes.EmptyElement(tag, lenByte); } var arguments = new INode[argCount]; for (var i = 0; i < argCount; ++i) { arguments[i] = DecodeExpression(input); } INode content = null; if (hasContent) { content = DecodeExpression(input); } return new Nodes.GenericElement(tag, content, lenByte, arguments); } protected INode DecodeGenericElementWithVariableArguments(BinaryReader input, TagType tag, int length, String lenByte, int minCount, int maxCount) { var end = input.BaseStream.Position + length; var args = new List<INode>(); for (var i = 0; i < maxCount && input.BaseStream.Position < end; ++i) { args.Add(DecodeExpression(input)); } return new Nodes.GenericElement(tag, null, lenByte, args); } protected INode DecodeGenericSurroundingTag(BinaryReader input, TagType tag, int length, String lenByte) { if (length != 1) throw new ArgumentOutOfRangeException("length"); List<byte> insideLenByte = new List<byte> { }; var status = GetInteger(input, insideLenByte); lenByte += BitConverter.ToString(insideLenByte.ToArray()).Replace("-", String.Empty); if (status == 0) return new Nodes.CloseTag(tag, lenByte); if (status == 1) return new Nodes.OpenTag(tag, lenByte, null); /* should be lenByte or insideLenByte? */ throw new InvalidDataException(); } #endregion }
#region Specific protected INode DecodeZeroPaddedValue(BinaryReader input, TagType tag, int length, String lenByte) { var val = DecodeExpression(input); var arg = DecodeExpression(input); return new GenericElement(tag, val, lenByte, arg); } protected INode DecodeColor(BinaryReader input, TagType tag, int length, String lenByte) { var t = input.ReadByte(); // I think the byte should be added if (length == 1 && t == 0xEC) return new Nodes.CloseTag(tag, lenByte + t.ToString("X2")); var color = DecodeExpression(input, (DecodeExpressionType)t); return new Nodes.OpenTag(tag, lenByte, color); } protected INode DecodeFormat(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var arg1 = DecodeExpression(input); var arg2 = new Nodes.StaticByteArray(input.ReadBytes((int)(end - input.BaseStream.Position))); return new Nodes.GenericElement(tag, null, lenByte, arg1, arg2); } protected INode DecodeIf(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var condition = DecodeExpression(input); INode trueValue, falseValue; DecodeConditionalOutputs(input, (int)end, out trueValue, out falseValue); return new Nodes.IfElement(tag, condition, trueValue, falseValue, lenByte); } protected INode DecodeIfEquals(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var left = DecodeExpression(input); var right = DecodeExpression(input); /* var trueValue = DecodeExpression(input); INode falseValue = null; if (input.BaseStream.Position != end) falseValue = DecodeExpression(input);*/ INode trueValue, falseValue; DecodeConditionalOutputs(input, (int)end, out trueValue, out falseValue); return new Nodes.IfEqualsElement(tag, left, right, trueValue, falseValue, lenByte); } protected void DecodeConditionalOutputs(BinaryReader input, int end, out INode trueValue, out INode falseValue) { var exprs = new List<INode>(); while (input.BaseStream.Position != end) { var expr = DecodeExpression(input); exprs.Add(expr); } // Only one instance with more than two expressions (LogMessage.en[1115][4]) // TODO: Not sure how it should be handled, discarding all but first and second for now. if (exprs.Count > 0) trueValue = exprs[0]; else trueValue = null; if (exprs.Count > 1) falseValue = exprs[1]; else falseValue = null; } protected INode DecodeSwitch(BinaryReader input, TagType tag, int length, String lenByte) { var end = input.BaseStream.Position + length; var caseSwitch = DecodeExpression(input); var cases = new Dictionary<int, INode>(); var i = 1; while (input.BaseStream.Position < end) cases.Add(i++, DecodeExpression(input)); return new Nodes.SwitchElement(tag, caseSwitch, cases, lenByte); } #endregion

下面是處理指令長度的函式。在一般情況下,指令長度只會有一個byte,但若數字較大也會需要有兩個bytes的時候。

用舉例來看看下面的程式碼。當指令只有一個byte0xC8,這個byte會被放進lenByte裡面回傳(因為List是reference type,在函式裡加東西進去在函式外也有效)。因為唯一的byte被讀完了,這時候的GetInteger(input, type, lenByte)可以想成GetInteger(空的input, 0xC8, lenByte{0xC8})

#region Shared protected static int GetInteger(BinaryReader input, List<byte> lenByte) { // added new function var t = input.ReadByte(); var type = (IntegerType)t; lenByte.Add(t); return GetInteger(input, type, lenByte); } protected static int GetInteger(BinaryReader input, IntegerType type, List<byte> lenByte) { const byte ByteLengthCutoff = 0xF0; var t = (byte)type; if (t < ByteLengthCutoff) return t - 1; switch (type) { case IntegerType.Byte: { byte res = input.ReadByte(); lenByte.Add(res); return (res); } case IntegerType.ByteTimes256: { byte res = input.ReadByte(); lenByte.Add(res); return (res * 256); } case IntegerType.Int16: { int v = 0; byte res = input.ReadByte(); lenByte.Add(res); v |= res << 8; res = input.ReadByte(); lenByte.Add(res); v |= res; return (v); } case IntegerType.Int24: { int v = 0; byte res = input.ReadByte(); lenByte.Add(res); v |= res << 16; res = input.ReadByte(); lenByte.Add(res); v |= res << 8; res = input.ReadByte(); lenByte.Add(res); v |= res; return (v); } case IntegerType.Int32: { int v = 0; byte res = input.ReadByte(); lenByte.Add(res); v |= res << 24; res = input.ReadByte(); lenByte.Add(res); v |= res << 16; res = input.ReadByte(); lenByte.Add(res); v |= res << 8; res = input.ReadByte(); lenByte.Add(res); v |= res; return (v); } default: throw new NotSupportedException(); } } #endregion

接著修改以下相應檔案(附例子):

  • DefaultElement.cs
  • EmptyElement.cs
  • GenericElement.cs
  • StaticInteger.cs
    • 199C8
  • CloseTag.csOpenTag.cs
    • 兩者皆會被<Emphasis></Emphasis><Color(數字)></color>兩種狀況呼叫。
    • <Emphasis><Value>IntegerParameter(1)</Value></Emphasis><hex:021A020203> <Value>IntegerParameter(1)</Value> <hex:021A020103>
    • <Color(-15523537)><Unknown14>FEFFB1BACD</Unknown14>選擇大國防聯軍<Unknown14>EC</Unknown14></Color><hex:021306FEFF13212F03> <Unknown14>FEFFB1BACD</Unknown14>選擇大國防聯軍<Unknown14>EC</Unknown14> <hex:021302EC03>
    • 其餘字串交由其他檔案處理
  • IfElement.csIfEqualsElement.cs
    • <If(GreaterThan(IntegerParameter(1),9999))>9,999+<Else/><Format(IntegerParameter(1),FF022C)/></If>
    • <hex:02081A GreaterThan(IntegerParameter(1),9999) FF07>9,999+<hex:FF0A> <Format(IntegerParameter(1),FF022C)/> <hex:03>
    • 其餘字串交由其他檔案處理
  • SwitchElement.cs
    • <Switch(IntegerParameter(2))><Case(1)>格里達尼亞新街</Case><Case(2)>彎枝牧場</Case><Case(3)>霍桑山寨</Case><Case(4)>石場水車</Case><Case(5)>恬靜路營地</Case><Case(6)>秋瓜浮村</Case></Switch><hex:020963E803FF16>格里達尼亞新街<hex:FF0D>彎枝牧場<hex:FF0D>霍桑山寨<hex:FF0D>石場水車<hex:FF10>恬靜路營地<hex:FF0D>秋瓜浮村<hex:03>
  • TopLevelParameter.cs
    • 觀察程式碼,這個建構子會在DecodeExpression裡面,當代表表示式類型的byte在0xD00xDF之間(含)時呼叫。
    • TopLevelParameter(222)DF
  • Comparison.cs
    • Equal(IntegerParameter(1),1)E4 IntegerParameter(1),1
  • ArgumentCollection.cs
    • (IntegerParameter(1),1)IntegerParameter(1) 02
  • Parameter.cs
    • IntegerParameter(1)E802

通常是將各個INode新增代表長度的byte(原本程式會將這些bytes轉成人類可讀的數字,但現在我們要繼續保留HEX的形式),方便我們output時使用。

需要修改的地方包括properties、get functions、ToString()。以DefaultElement.cs為例。我們增加了作為String傳入的lenByte,接著修改ToString()函數。

namespace SaintCoinach.Text.Nodes { public class DefaultElement : INode { private readonly TagType _Tag; private readonly StaticByteArray _Data; private readonly String _LenByte; public TagType Tag { get { return _Tag; } } public INode Data { get { return _Data; } } public String LenByte { get { return _LenByte; } } NodeFlags INode.Flags { get { return NodeFlags.IsStatic; } } public DefaultElement(TagType tag, byte[] innerBuffer, String lenByte) { _Tag = tag; _Data = new StaticByteArray(innerBuffer); _LenByte = lenByte; } public override string ToString() { var sb = new StringBuilder(); ToString(sb); return sb.ToString(); } public void ToString(StringBuilder builder) { // edit here!!!! builder.Append(StringTokens.TagOpen); builder.Append("hex:02"); builder.Append(((byte)Tag).ToString("X2")); /* X means hex, 2 means 2-digit */ builder.Append(LenByte); if (_Data.Value.Length == 0) { builder.Append("03"); builder.Append(StringTokens.TagClose); } else { _Data.ToString(builder); builder.Append("03"); builder.Append(StringTokens.TagClose); } /* builder.Append("DefaultElement"); builder.Append(StringTokens.TagOpen); builder.Append(Tag); if (_Data.Value.Length == 0) { builder.Append(StringTokens.ElementClose); builder.Append(StringTokens.TagClose); } else { builder.Append(StringTokens.TagClose); _Data.ToString(builder); builder.Append(StringTokens.TagOpen); builder.Append(StringTokens.ElementClose); builder.Append(Tag); builder.Append(StringTokens.TagClose); } */ } public T Accept<T>(SaintCoinach.Text.Nodes.INodeVisitor<T> visitor) { return visitor.Visit(this); } } }

4 Launch4j

在Eclipse輸出jar:

  1. 檔案 > 匯出 > 可執行的jar檔案
  2. 選項:extract required libraries into generated JAR

JAR轉exe:

  1. 下載launch4j
  2. 解壓縮,執行launch4j.exe
  3. Basic分頁,選擇output file和Jar
  4. Header分頁,選擇GUI
  5. JRE分頁,使用Eclipse的JRE
  6. 我們將原本位於 C:\Users\User\.p2\pool\plugins\org.eclipse.justj.openjdk.hotspot.jre.full.win32.x86_64_16.0.1.v20210528-1205\jre的bin和lib資料夾複製進和exe同層的jre資料夾 改用4.1所記載的方式產生JRE
  7. 在bundled JRE path的地方填 jre
  8. 最小JRE版本為16.0.1
  9. 點擊上方按鈕儲存設定,然後點擊播放鍵產生.exe

4.1 縮減JRE大小

Check dependencies:

jdeps -s <輸出的jar>

Example Output:

FFXIVChnTextPatch-0211.jar -> java.base
FFXIVChnTextPatch-0211.jar -> java.desktop
FFXIVChnTextPatch-0211.jar -> java.logging
FFXIVChnTextPatch-0211.jar -> java.naming
FFXIVChnTextPatch-0211.jar -> java.security.jgss
FFXIVChnTextPatch-0211.jar -> java.sql
FFXIVChnTextPatch-0211.jar -> not found

Produce JRE:

jlink --module-path <JDK module path> --add-modules <listed modules> --output <output path>

Example:

jlink --module-path "D:\Java\jdk-16.0.1\jmods" --add-modules java.base,java.desktop,java.logging,java.naming,java.security.jgss,java.sql --output ".\jre"

4.2 參考資料

5 結語

到這邊,基本上就解決了所有手動漢化會遇到的問題。

A 附錄

A.1 測試自己有沒有成功

除了直接進入遊戲以外,也可以用「迴圈」測試自己有沒有漢化正確。通常會出問題的地方在於SaintCoinach對於指令碼的處理。

  1. 備份遊戲檔案
  2. 先用已漢化的覆蓋檔覆蓋遊戲檔案
  3. 使用修改過的SaintCoinach輸出CSV
  4. 將rawexd資料夾下的CSV檔丟到FFXIVChnTextPatch的resource資料夾下
  5. FFXIVChnTextPatch以CSV模式執行一遍漢化
  6. 再次使用修改過的SaintCoinach輸出CSV

如果以上步驟會出問題,就表示漢化處理上有失敗的地方,即使進入遊戲也無法正常讀取。

A.2 Build Bug

這是一開始用GitHub版本的漢化器的bug,後來改用反編譯版本後已不需要,留做參考。

嘗試一、把下面這行註解掉(後來發現下面的解決方法,這個方法僅留供參考)

percentPanel.percentShow((double)(++fileCount) / (double)fileList.size());

嘗試二、在ReplaceEXDF.java新增this.percentPanel = percentPanel;

public ReplaceEXDF(String pathToIndexSE, String pathToIndexCN, PercentPanel percentPanel) { this.pathToIndexSE = pathToIndexSE; this.pathToIndexCN = pathToIndexCN; this.fileList = new ArrayList<String>(); this.exQuestMap = new HashMap(); this.transMap = new HashMap<>(); this.lang = Config.getProperty("Language"); // added this.percentPanel = percentPanel; }

A.3 字體問題觀察

  • 覆蓋後再使用漢化器漢化,會發現字體包體積變大,進遊戲後顯示正常。推測是因為ReplaceFont是在後面增加字型,沒有影響到前面原漢化包已經加上去的字型。
  • 使用過去的使用起來沒問題的原版漢化器測試,發現也有字型亂碼的問題。推測是遊戲更新導致漢化器的ReplaceFont出了問題。
  • 實際觀察binary檔案,發現用漢化器添加字型就是檔案後面加上(4948216 - 4297736 = 650480)行(一行16 bytes)。
  • 而比較未漢化的檔案和覆蓋用的漢化檔,發現2、2383489、2383492行有細微的變動。但除此之外,在3624696行以前都相同。
    • 接著,第3625697-3784843(原版)/3832883(覆蓋版)行有明顯的差異。
    • 原版的第3784844-4145207行和覆蓋版的3832884-4193247相同。
    • 原版的第4244753-4249744行和覆蓋版的4310561-4315552相同。
    • 原版的第4288864-4297736行和覆蓋版的4363560-4372432相同。

A.3.1 SQPACK研究

用一個簡單的程式證明覆蓋檔擁有漢化器所注入的字型,只是散落四處:

a = {} b = [] with open('Han.txt', 'r') as f: number = 0 for line in f: a[line] = number number += 1 with open('AfterProcess Trim.txt', 'r') as f: for line in f: if line in a: # print(line, a[line]) pass else: print("line not found!", line)

結果沒有print出任何東西。

A.4 其他

  • 文本中的指令就算有問題,也不一定會在讀取遊戲時就崩潰,有可能在需要顯示該文字時才出問題。
  • 6.1更新發現:Completion.csv中,#2200-#2221有指令編號,需隨版本更新。